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:
@@ -203,8 +203,8 @@ Item {
|
|||||||
id: readoutPanel
|
id: readoutPanel
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 0
|
anchors.margins: 0
|
||||||
onExportRequested: function(filePath) {
|
onExportRequested: function(filePath, extra) {
|
||||||
waferView.exportImage(filePath);
|
waferView.exportImage(filePath, extra);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ ColumnLayout {
|
|||||||
property alias heatmapBlend: heatmapSlider.value
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
property alias showThickness: thicknessToggle.checked
|
property alias showThickness: thicknessToggle.checked
|
||||||
|
|
||||||
signal exportRequested(string filePath)
|
signal exportRequested(string filePath, string extra)
|
||||||
|
|
||||||
component PanelCheckBox: CheckBox {
|
component PanelCheckBox: CheckBox {
|
||||||
id: toggle
|
id: toggle
|
||||||
@@ -80,8 +80,8 @@ ColumnLayout {
|
|||||||
|
|
||||||
StreamControlPanel {
|
StreamControlPanel {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
onExportRequested: function(filePath) {
|
onExportRequested: function(filePath, extra) {
|
||||||
root.exportRequested(filePath);
|
root.exportRequested(filePath, extra);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Rectangle {
|
|||||||
border.color: Theme.sideBorder
|
border.color: Theme.sideBorder
|
||||||
border.width: 1
|
border.width: 1
|
||||||
|
|
||||||
signal exportRequested(string filePath)
|
signal exportRequested(string filePath, string extra)
|
||||||
|
|
||||||
property int _liveSecs: 0
|
property int _liveSecs: 0
|
||||||
Connections {
|
Connections {
|
||||||
@@ -310,7 +310,14 @@ Rectangle {
|
|||||||
nameFilters: ["PNG files (*.png)"]
|
nameFilters: ["PNG files (*.png)"]
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
var path = String(selectedFile).replace(/^file:\/\//, "");
|
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
|
id: replaceDialog
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportImage(filePath) {
|
function exportImage(filePath, extra) {
|
||||||
return map.export_image(filePath);
|
return map.export_image(filePath, extra || "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from PySide6.QtGui import (
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.models.frame_stats import compute_stats
|
||||||
from pygui.backend.visualization.rbf_heatmap import (
|
from pygui.backend.visualization.rbf_heatmap import (
|
||||||
ellipse_alpha,
|
ellipse_alpha,
|
||||||
interpolate_field,
|
interpolate_field,
|
||||||
@@ -36,6 +37,16 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
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).
|
# 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.
|
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
|
||||||
@Slot(str, result=bool)
|
@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
|
"""Export the current wafer map rendering to a PNG file
|
||||||
|
|
||||||
Args:
|
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:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
@@ -321,33 +334,47 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
w, h = int(self.width()), int(self.height())
|
w, h = int(self.width()), int(self.height())
|
||||||
if w <= 0 or h <= 0:
|
if w <= 0 or h <= 0:
|
||||||
return False
|
return False
|
||||||
metrics = self._metrics_text()
|
lines = [ln for ln in (readout_text(self._values), extra) if ln]
|
||||||
footer_h = 28 if metrics else 0
|
footer_h = 28 * len(lines)
|
||||||
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
|
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
|
||||||
img.fill(0) # transparent background
|
img.fill(0) # transparent background
|
||||||
painter = QPainter(img)
|
painter = QPainter(img)
|
||||||
self.paint(painter)
|
self.paint(painter)
|
||||||
if metrics:
|
self._paint_extremes(painter)
|
||||||
footer = QRectF(0, h, w, footer_h)
|
if lines:
|
||||||
painter.fillRect(footer, QColor("#101014"))
|
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
|
||||||
painter.setPen(QColor("#CBD5E1"))
|
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()
|
painter.end()
|
||||||
return bool(img.save(file_path))
|
return bool(img.save(file_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Export failed: %s", e)
|
log.error("Export failed: %s", e)
|
||||||
return False
|
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 ─────────────────────────────────────────────────────────
|
# ── 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:
|
def _rebuild_thickness(self) -> None:
|
||||||
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
||||||
if not self._sensors or not self._thickness_data:
|
if not self._sensors or not self._thickness_data:
|
||||||
|
|||||||
@@ -111,6 +111,76 @@ def test_export_image_includes_metrics_footer(tmp_path):
|
|||||||
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
|
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):
|
def test_export_image_no_data_fails(tmp_path):
|
||||||
"""No file loaded / no stream played -> nothing meaningful to export."""
|
"""No file loaded / no stream played -> nothing meaningful to export."""
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|||||||
Reference in New Issue
Block a user