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_view import GraphView
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"GraphQuickItem", "GraphView", "WaferMapItem",
|
||||
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
|
||||
"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
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
@@ -22,13 +23,15 @@ from PySide6.QtGui import (
|
||||
QPainter,
|
||||
QPen,
|
||||
QPolygon,
|
||||
)
|
||||
) # fmt: skip
|
||||
from PySide6.QtQml import QmlElement
|
||||
from PySide6.QtQuick import QQuickPaintedItem
|
||||
|
||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
QML_IMPORT_NAME = "ISC.Wafer"
|
||||
QML_IMPORT_MAJOR_VERSION = 1
|
||||
|
||||
@@ -197,10 +200,10 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def thicknessData(self, val:list) -> None:
|
||||
self._thickness_data = list(val or [])
|
||||
self._rebuild_thickness()
|
||||
self._thicknessChanged.emit()
|
||||
self.thicknessChanged.emit()
|
||||
self.update
|
||||
|
||||
@property(bool, notify=showThicknessChanged)
|
||||
@Property(bool, notify=showThicknessChanged)
|
||||
def showThickness(self) -> bool:
|
||||
return self._show_thickness
|
||||
|
||||
@@ -265,14 +268,59 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
if not file_path:
|
||||
return False
|
||||
try:
|
||||
image = self.grabToImage()
|
||||
image.save(file_path, "PNG")
|
||||
result = self.grabToImage()
|
||||
img = result.image()
|
||||
img.save(file_path, "PNG")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error("Export failed: %s", e)
|
||||
return False
|
||||
|
||||
# ── 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:
|
||||
self._rebuild()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user