From 034f13b7178e1f9a247cd6818b8378d52c5dc745 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 15:39:30 -0700 Subject: [PATCH] feat(map): add peak highlights and live metrics to PNG export - Draw rings around hottest and coldest sensors on export. - Extend export_image to support second line for live metrics. - Format detailed sensor stats in export footer. - Add test coverage for footer rows, overlays, and stats. --- src/pygui/ISC/Tabs/WaferMapTab.qml | 4 +- .../ISC/Tabs/components/ReadoutPanel.qml | 6 +- .../Tabs/components/StreamControlPanel.qml | 11 ++- .../ISC/Tabs/components/WaferMapView.qml | 4 +- .../backend/visualization/wafer_map_item.py | 57 +++++++++++---- tests/test_wafer_map_item.py | 70 +++++++++++++++++++ 6 files changed, 128 insertions(+), 24 deletions(-) diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index e369202..2655368 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -203,8 +203,8 @@ Item { id: readoutPanel anchors.fill: parent anchors.margins: 0 - onExportRequested: function(filePath) { - waferView.exportImage(filePath); + onExportRequested: function(filePath, extra) { + waferView.exportImage(filePath, extra); } } } diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 725fe8e..8abcbd3 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -15,7 +15,7 @@ ColumnLayout { property alias heatmapBlend: heatmapSlider.value property alias showThickness: thicknessToggle.checked - signal exportRequested(string filePath) + signal exportRequested(string filePath, string extra) component PanelCheckBox: CheckBox { id: toggle @@ -80,8 +80,8 @@ ColumnLayout { StreamControlPanel { Layout.fillWidth: true - onExportRequested: function(filePath) { - root.exportRequested(filePath); + onExportRequested: function(filePath, extra) { + root.exportRequested(filePath, extra); } } diff --git a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml index d1b66ec..187669d 100644 --- a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml +++ b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml @@ -15,7 +15,7 @@ Rectangle { border.color: Theme.sideBorder border.width: 1 - signal exportRequested(string filePath) + signal exportRequested(string filePath, string extra) property int _liveSecs: 0 Connections { @@ -310,7 +310,14 @@ Rectangle { nameFilters: ["PNG files (*.png)"] onAccepted: { var path = String(selectedFile).replace(/^file:\/\//, ""); - root.exportRequested(path); + var extra = ""; + if (streamController.mode === "live") { + extra = "Frames: " + streamController.receivedCount + + " Errors: " + streamController.errorCount + + " Resyncs: " + streamController.resyncCount + + " Elapsed: " + root.fmtTime(root._liveSecs); + } + root.exportRequested(path, extra); } } } diff --git a/src/pygui/ISC/Tabs/components/WaferMapView.qml b/src/pygui/ISC/Tabs/components/WaferMapView.qml index f65258d..75eb6b2 100644 --- a/src/pygui/ISC/Tabs/components/WaferMapView.qml +++ b/src/pygui/ISC/Tabs/components/WaferMapView.qml @@ -58,7 +58,7 @@ Item { id: replaceDialog } - function exportImage(filePath) { - return map.export_image(filePath); + function exportImage(filePath, extra) { + return map.export_image(filePath, extra || ""); } } diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 54c05d0..46e5146 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -27,6 +27,7 @@ from PySide6.QtGui import ( from PySide6.QtQml import QmlElement from PySide6.QtQuick import QQuickPaintedItem +from pygui.backend.models.frame_stats import compute_stats from pygui.backend.visualization.rbf_heatmap import ( ellipse_alpha, interpolate_field, @@ -36,6 +37,16 @@ from pygui.backend.wafer.zwafer_models import Sensor log = logging.getLogger(__name__) + +def readout_text(values: list[float]) -> str: + """Full READOUT stats line for the export footer; '' when no data.""" + if not values: + return "" + s = compute_stats(values) + return (f"Sensors: {len(values)} Min: {s.min:.2f}°C (#{s.min_index + 1}) " + f"Max: {s.max:.2f}°C (#{s.max_index + 1}) Diff: {s.diff:.2f} " + f"Avg: {s.avg:.2f} σ: {s.sigma:.2f} 3σ: {s.three_sigma:.2f}") + QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_MAJOR_VERSION = 1 @@ -301,11 +312,13 @@ class WaferMapItem(QQuickPaintedItem): # chosen output dir, plus an optional summary CSV of (file, min/max/mean). # No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1. @Slot(str, result=bool) - def export_image(self, file_path: str) -> bool: + @Slot(str, str, result=bool) + def export_image(self, file_path: str, extra: str = "") -> bool: """Export the current wafer map rendering to a PNG file Args: - file_pat: Absolute path for the output PNG. + file_path: Absolute path for the output PNG. + extra: Optional second footer line (live stream metrics). Returns: True on success, False on failure @@ -321,33 +334,47 @@ class WaferMapItem(QQuickPaintedItem): w, h = int(self.width()), int(self.height()) if w <= 0 or h <= 0: return False - metrics = self._metrics_text() - footer_h = 28 if metrics else 0 + lines = [ln for ln in (readout_text(self._values), extra) if ln] + footer_h = 28 * len(lines) img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32) img.fill(0) # transparent background painter = QPainter(img) self.paint(painter) - if metrics: - footer = QRectF(0, h, w, footer_h) - painter.fillRect(footer, QColor("#101014")) + self._paint_extremes(painter) + if lines: + painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014")) painter.setPen(QColor("#CBD5E1")) - painter.drawText(footer, Qt.AlignmentFlag.AlignCenter, metrics) + for i, line in enumerate(lines): + painter.drawText(QRectF(0, h + 28 * i, w, 28), + Qt.AlignmentFlag.AlignCenter, line) painter.end() return bool(img.save(file_path)) except Exception as e: log.error("Export failed: %s", e) return False - def _metrics_text(self) -> str: - """One-line metric readout for the export footer; '' when no data.""" - if not self._values: - return "" - vs = self._values - return (f"Sensors: {len(vs)} Min: {min(vs):.2f}°C " - f"Max: {max(vs):.2f}°C Avg: {sum(vs) / len(vs):.2f}°C") # ── internal ───────────────────────────────────────────────────────── + def _paint_extremes(self, painter: QPainter) -> None: + """Ring the hottest (high color) and coldest (low color) markers. + + Export-only overlay — paint() itself stays untouched so the live + view is unchanged. + """ + if not self._values or not self._markers: + return + s = compute_stats(self._values) + ring_r = self._marker_r + 5 + painter.setBrush(Qt.BrushStyle.NoBrush) + for idx, color in ((s.max_index, self._high_color), + (s.min_index, self._low_color)): + if idx not in self._markers: + continue + px, py = self._markers[idx] + painter.setPen(QPen(color, 2)) + painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r) + def _rebuild_thickness(self) -> None: """Interpolate thickness data into a gray/orange heatmap QImage.""" if not self._sensors or not self._thickness_data: diff --git a/tests/test_wafer_map_item.py b/tests/test_wafer_map_item.py index fc34f72..2fce50a 100644 --- a/tests/test_wafer_map_item.py +++ b/tests/test_wafer_map_item.py @@ -111,6 +111,76 @@ def test_export_image_includes_metrics_footer(tmp_path): assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255 +def test_export_image_extra_line_grows_footer(tmp_path): + """Live export passes an extra metrics line; the footer gains a second + row for it. Review export (no extra) keeps the single-row footer.""" + from PySide6.QtGui import QImage + + from pygui.backend.visualization.wafer_map_item import WaferMapItem + + item = WaferMapItem() + item.setWidth(200) + item.setHeight(200) + item.values = [10.0, 20.0, 30.0] + + review = tmp_path / "review.png" + live = tmp_path / "live.png" + assert item.export_image(str(review)) is True + assert item.export_image(str(live), "Frames: 42 Errors: 0") is True + + h_review = QImage(str(review)).height() + h_live = QImage(str(live)).height() + assert h_review > 200 # readout footer present + assert h_live > h_review # extra live row appended + + +def test_export_image_highlights_hottest_and_coldest(tmp_path): + """The exported map rings the hottest sensor in the high color and the + coldest in the low color. With no bands set every marker fill is the + in-range green, so any high/low-colored pixels must come from the rings.""" + from PySide6.QtGui import QImage + + from pygui.backend.visualization.wafer_map_item import WaferMapItem + + item = WaferMapItem() + item.setWidth(200) + item.setHeight(200) + item.sensors = [ + {"label": "1", "x": -50.0, "y": 0.0}, + {"label": "2", "x": 0.0, "y": 0.0}, + {"label": "3", "x": 50.0, "y": 0.0}, + ] + item.values = [10.0, 20.0, 30.0] + out = tmp_path / "wafer.png" + assert item.export_image(str(out)) is True + + img = QImage(str(out)) + found = {img.pixelColor(x, y).name() + for y in range(img.height()) for x in range(img.width())} + assert "#ef4444" in found # hottest ring (sensorHigh) + assert "#5b9df5" in found # coldest ring (sensorLow) + + +def test_readout_text_full_stats(): + """Export footer readout carries every READOUT stat with sensor indices. + + Worked example: [10, 20, 30] → min 10 (#1), max 30 (#3), diff 20, + avg 20, σ = sqrt(200/3) ≈ 8.16, 3σ ≈ 24.49. + """ + from pygui.backend.visualization.wafer_map_item import readout_text + + assert readout_text([10.0, 20.0, 30.0]) == ( + "Sensors: 3 Min: 10.00°C (#1) Max: 30.00°C (#3) " + "Diff: 20.00 Avg: 20.00 σ: 8.16 3σ: 24.49" + ) + + +def test_readout_text_empty(): + from pygui.backend.visualization.wafer_map_item import readout_text + + assert readout_text([]) == "" + + def test_export_image_no_data_fails(tmp_path): """No file loaded / no stream played -> nothing meaningful to export.""" from pygui.backend.visualization.wafer_map_item import WaferMapItem