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
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user