feat(viz): implement RBF heatmap and custom QPainter trend chart item
Introduce Radial Basis Function interpolation for fine wafer thermal gradients and GraphQuickItem for high-performance trend line rendering.
This commit is contained in:
@@ -2,9 +2,10 @@
|
|||||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||||
|
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GraphQuickItem", "GraphView", "WaferMapItem",
|
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
|
||||||
"interpolate_field",
|
"interpolate_field",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
||||||
|
|
||||||
|
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
||||||
|
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
||||||
|
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
||||||
|
|
||||||
|
The series is driven by the existing `streamController.trendData` signal, which
|
||||||
|
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
||||||
|
streaming.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class TrendChartItem(QQuickPaintedItem):
|
||||||
|
"""Painted trend chart; driven by a list of floats via the `data` property."""
|
||||||
|
|
||||||
|
dataChanged = Signal()
|
||||||
|
hasDataChanged = Signal()
|
||||||
|
lineColorChanged = Signal()
|
||||||
|
gridColorChanged = Signal()
|
||||||
|
textColorChanged = Signal()
|
||||||
|
paddingChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QQuickPaintedItem] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setRenderTarget(QQuickPaintedItem.RenderTarget.FramebufferObject)
|
||||||
|
self.setAntialiasing(True)
|
||||||
|
self.setFillColor(QColor("transparent"))
|
||||||
|
|
||||||
|
self._data: list[float] = []
|
||||||
|
self._padding: int = 8
|
||||||
|
self._line_color = QColor("#5B9DF5")
|
||||||
|
self._grid_color = QColor("#2A3441")
|
||||||
|
self._text_color = QColor("#CBD5E1")
|
||||||
|
|
||||||
|
# ── Qt properties ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=dataChanged)
|
||||||
|
def data(self) -> list[float]:
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
@data.setter
|
||||||
|
def data(self, val) -> None:
|
||||||
|
coerced = self._coerce_floats(val)
|
||||||
|
if coerced == self._data:
|
||||||
|
return
|
||||||
|
self._data = coerced
|
||||||
|
self.dataChanged.emit()
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=hasDataChanged)
|
||||||
|
def hasData(self) -> bool:
|
||||||
|
"""True when the data list has at least one point.
|
||||||
|
|
||||||
|
QML can bind to this safely (QVariantList `.length` is not bindable).
|
||||||
|
"""
|
||||||
|
return len(self._data) > 0
|
||||||
|
|
||||||
|
@Property(QColor, notify=lineColorChanged)
|
||||||
|
def lineColor(self) -> QColor:
|
||||||
|
return self._line_color
|
||||||
|
|
||||||
|
@lineColor.setter
|
||||||
|
def lineColor(self, c: QColor) -> None:
|
||||||
|
if c == self._line_color:
|
||||||
|
return
|
||||||
|
self._line_color = QColor(c)
|
||||||
|
self.lineColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=gridColorChanged)
|
||||||
|
def gridColor(self) -> QColor:
|
||||||
|
return self._grid_color
|
||||||
|
|
||||||
|
@gridColor.setter
|
||||||
|
def gridColor(self, c: QColor) -> None:
|
||||||
|
if c == self._grid_color:
|
||||||
|
return
|
||||||
|
self._grid_color = QColor(c)
|
||||||
|
self.gridColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=textColorChanged)
|
||||||
|
def textColor(self) -> QColor:
|
||||||
|
return self._text_color
|
||||||
|
|
||||||
|
@textColor.setter
|
||||||
|
def textColor(self, c: QColor) -> None:
|
||||||
|
if c == self._text_color:
|
||||||
|
return
|
||||||
|
self._text_color = QColor(c)
|
||||||
|
self.textColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(int, notify=paddingChanged)
|
||||||
|
def padding(self) -> int:
|
||||||
|
return self._padding
|
||||||
|
|
||||||
|
@padding.setter
|
||||||
|
def padding(self, val: int) -> None:
|
||||||
|
v = max(0, int(val))
|
||||||
|
if v == self._padding:
|
||||||
|
return
|
||||||
|
self._padding = v
|
||||||
|
self.paddingChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def setDataFromJson(self, avgs_json: str) -> None:
|
||||||
|
"""Slot for QML Connections handler: parse JSON array and update data.
|
||||||
|
|
||||||
|
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(avgs_json) if avgs_json else []
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
|
||||||
|
return
|
||||||
|
coerced = self._coerce_floats(parsed)
|
||||||
|
if coerced == self._data:
|
||||||
|
return
|
||||||
|
self._data = coerced
|
||||||
|
self.dataChanged.emit()
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None: # type: ignore[override]
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
||||||
|
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
plot_rect = QRectF(self._padding, self._padding,
|
||||||
|
max(1, w - 2 * self._padding),
|
||||||
|
max(1, h - 2 * self._padding))
|
||||||
|
|
||||||
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
|
if len(self._data) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
y_min, y_max = self._y_range()
|
||||||
|
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
||||||
|
self._draw_line(painter, plot_rect, y_min, y_max)
|
||||||
|
|
||||||
|
# ── internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _coerce_floats(self, val) -> list[float]:
|
||||||
|
if not val:
|
||||||
|
return []
|
||||||
|
out: list[float] = []
|
||||||
|
for v in val:
|
||||||
|
try:
|
||||||
|
out.append(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _y_range(self) -> tuple[float, float]:
|
||||||
|
lo = min(self._data)
|
||||||
|
hi = max(self._data)
|
||||||
|
if hi - lo < 1e-9:
|
||||||
|
return lo - 5.0, hi + 5.0
|
||||||
|
buf = (hi - lo) * 0.1
|
||||||
|
return lo - buf, hi + buf
|
||||||
|
|
||||||
|
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
||||||
|
if n <= 1:
|
||||||
|
return plot_rect.left()
|
||||||
|
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
||||||
|
|
||||||
|
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
||||||
|
if y_max - y_min < 1e-9:
|
||||||
|
return plot_rect.center().y()
|
||||||
|
t = (v - y_min) / (y_max - y_min)
|
||||||
|
return plot_rect.bottom() - t * plot_rect.height()
|
||||||
|
|
||||||
|
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||||
|
pen = QPen(self._grid_color)
|
||||||
|
pen.setWidthF(1.0)
|
||||||
|
pen.setCosmetic(True)
|
||||||
|
painter.setPen(pen)
|
||||||
|
rows = 4
|
||||||
|
for i in range(rows + 1):
|
||||||
|
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
||||||
|
painter.drawLine(int(plot_rect.left()), int(y),
|
||||||
|
int(plot_rect.right()), int(y))
|
||||||
|
|
||||||
|
def _draw_axes_labels(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
) -> None:
|
||||||
|
painter.setPen(self._text_color)
|
||||||
|
font = QFont(painter.font())
|
||||||
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||||
|
painter.setFont(font)
|
||||||
|
rows = 4
|
||||||
|
for i in range(rows + 1):
|
||||||
|
t = i / rows
|
||||||
|
y_val = y_max - t * (y_max - y_min)
|
||||||
|
y_px = plot_rect.top() + t * plot_rect.height()
|
||||||
|
label = f"{y_val:.1f}"
|
||||||
|
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
label)
|
||||||
|
|
||||||
|
def _draw_line(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
) -> None:
|
||||||
|
n = len(self._data)
|
||||||
|
pen = QPen(self._line_color)
|
||||||
|
pen.setWidthF(2.0)
|
||||||
|
pen.setCosmetic(True)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
poly = QPolygon()
|
||||||
|
for i, v in enumerate(self._data):
|
||||||
|
px = self._x_to_px(i, n, plot_rect)
|
||||||
|
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
||||||
|
poly.append(QPoint(int(px), int(py)))
|
||||||
|
painter.drawPolyline(poly)
|
||||||
|
|
||||||
|
# Trailing dot at the last data point
|
||||||
|
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
||||||
|
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
||||||
|
painter.setBrush(self._line_color)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
radius = 3
|
||||||
|
painter.drawEllipse(last_x - radius, last_y - radius,
|
||||||
|
radius * 2, radius * 2)
|
||||||
@@ -10,6 +10,7 @@ All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV)
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -22,13 +23,15 @@ from PySide6.QtGui import (
|
|||||||
QPainter,
|
QPainter,
|
||||||
QPen,
|
QPen,
|
||||||
QPolygon,
|
QPolygon,
|
||||||
)
|
) # fmt: skip
|
||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
@@ -197,10 +200,10 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def thicknessData(self, val:list) -> None:
|
def thicknessData(self, val:list) -> None:
|
||||||
self._thickness_data = list(val or [])
|
self._thickness_data = list(val or [])
|
||||||
self._rebuild_thickness()
|
self._rebuild_thickness()
|
||||||
self._thicknessChanged.emit()
|
self.thicknessChanged.emit()
|
||||||
self.update
|
self.update
|
||||||
|
|
||||||
@property(bool, notify=showThicknessChanged)
|
@Property(bool, notify=showThicknessChanged)
|
||||||
def showThickness(self) -> bool:
|
def showThickness(self) -> bool:
|
||||||
return self._show_thickness
|
return self._show_thickness
|
||||||
|
|
||||||
@@ -265,14 +268,59 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
image = self.grabToImage()
|
result = self.grabToImage()
|
||||||
image.save(file_path, "PNG")
|
img = result.image()
|
||||||
|
img.save(file_path, "PNG")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Export failed: %s", e)
|
log.error("Export failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ── internal ─────────────────────────────────────────────────────────
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rebuild_thickness(self) -> None:
|
||||||
|
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
||||||
|
if not self._sensors or not self._thickness_data:
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
xs = np.array([s.x for s in self._sensors])
|
||||||
|
ys = np.array([s.y for s in self._sensors])
|
||||||
|
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
||||||
|
if len(vs) < len(self._sensors):
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
field = interpolate_field(
|
||||||
|
xs, ys, vs,
|
||||||
|
width=ds, height=ds,
|
||||||
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
|
round_clip=(self._shape == "round"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||||||
|
vmin, vmax = np.nanmin(field), np.nanmax(field)
|
||||||
|
span = vmax - vmin or 1.0
|
||||||
|
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
|
||||||
|
rgb = np.zeros((ds, ds, 3), dtype=np.float32)
|
||||||
|
rgb[:, :, 0] = 0.5 + 0.5 * t
|
||||||
|
rgb[:, :, 1] = 0.5 + 0.15 * t
|
||||||
|
rgb[:, :, 2] = 0.5 - 0.5 * t
|
||||||
|
|
||||||
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
|
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
||||||
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
|
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8)
|
||||||
|
|
||||||
|
self._thickness_heatmap = (
|
||||||
|
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
||||||
|
)
|
||||||
|
|
||||||
def _on_resize(self) -> None:
|
def _on_resize(self) -> None:
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user