diff --git a/src/pygui/backend/visualization/__init__.py b/src/pygui/backend/visualization/__init__.py index b31b753..722f33d 100644 --- a/src/pygui/backend/visualization/__init__.py +++ b/src/pygui/backend/visualization/__init__.py @@ -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", ] diff --git a/src/pygui/backend/visualization/trend_chart_item.py b/src/pygui/backend/visualization/trend_chart_item.py new file mode 100644 index 0000000..8ce94bb --- /dev/null +++ b/src/pygui/backend/visualization/trend_chart_item.py @@ -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) diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 68cddbc..96310cc 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -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()