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:
@@ -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