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.
This commit is contained in:
jack
2026-07-10 15:39:30 -07:00
parent 69753e35f9
commit 034f13b717
6 changed files with 128 additions and 24 deletions
+2 -2
View File
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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 || "");
}
}
@@ -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:
+70
View File
@@ -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