6003bde84d
- importZWaferCsv slot on FileBrowser — converts and adds to list - SourcePanel: import icon button → FileDialog → importSummary banner (auto-dismisses after 5s, loads the converted file into streamController) - wafer_map_item: showExtremes default → true
839 lines
33 KiB
Python
839 lines
33 KiB
Python
"""QQuickPaintedItem wafer map — ported from the replay app's ReplayWidget.
|
||
|
||
Draws:
|
||
• Radial ring template (concentric guides + crosshair axes + top notch)
|
||
• RBF heatmap layer (blended under markers via `blend` 0→1)
|
||
• Sensor marker circles colored by band (low/in_range/high)
|
||
• Numbered labels (toggle via `showLabels`)
|
||
|
||
All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import math
|
||
|
||
import numpy as np
|
||
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||
from PySide6.QtGui import (
|
||
QBrush,
|
||
QColor,
|
||
QFont,
|
||
QImage,
|
||
QPainter,
|
||
QPen,
|
||
QPolygon,
|
||
) # fmt: skip
|
||
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,
|
||
refine_field,
|
||
)
|
||
from pygui.backend.wafer.zwafer_models import Sensor
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
|
||
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
|
||
|
||
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
|
||
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
|
||
Returns (points, error) — error is "" on success.
|
||
"""
|
||
import csv
|
||
|
||
points: list[list[float]] = []
|
||
slot: str | None = None
|
||
try:
|
||
with open(file_path, newline="") as fh:
|
||
reader = csv.reader(fh)
|
||
header = next(reader, None)
|
||
if header is None:
|
||
return [], "Unable to read header row from CSV file."
|
||
if header and header[-1] == "": # trailing comma
|
||
header = header[:-1]
|
||
nfields = len(header)
|
||
if nfields != 8:
|
||
return [], "Incorrect field count in CSV header row."
|
||
for lno, row in enumerate(reader, start=2):
|
||
if row and row[-1] == "":
|
||
row = row[:-1]
|
||
if len(row) != nfields:
|
||
return [], f"Incorrect field count in CSV row {lno}"
|
||
if slot is None:
|
||
slot = row[3]
|
||
elif row[3] != slot:
|
||
break
|
||
try:
|
||
t2, x, y = float(row[4]), float(row[6]), float(row[7])
|
||
except ValueError:
|
||
return [], f"Malformed value in CSV row {lno}"
|
||
points.append([x, y, t2])
|
||
except OSError as exc:
|
||
return [], f"Unable to read CSV file: {exc}"
|
||
if not points:
|
||
return [], "No data found in CSV file."
|
||
return points, ""
|
||
|
||
|
||
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
|
||
|
||
|
||
@QmlElement
|
||
class WaferMapItem(QQuickPaintedItem):
|
||
"""Painted wafer map; driven by SessionController via QML property bindings."""
|
||
|
||
sensorsChanged = Signal()
|
||
valuesChanged = Signal()
|
||
bandsChanged = Signal()
|
||
targetChanged = Signal()
|
||
marginChanged = Signal()
|
||
blendChanged = Signal()
|
||
showLabelsChanged = Signal()
|
||
showExtremesChanged = Signal()
|
||
colorsChanged = Signal()
|
||
shapeChanged = Signal()
|
||
sizeChanged = Signal()
|
||
thicknessChanged = Signal()
|
||
showThicknessChanged = Signal()
|
||
hoveredIndexChanged = Signal()
|
||
hoverScaleChanged = Signal()
|
||
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._sensors: list[Sensor] = []
|
||
self._values: list[float] = []
|
||
self._bands: list[str] = []
|
||
self._target: float = 149.0
|
||
self._margin: float = 1.0
|
||
self._blend: float = 0.0
|
||
self._show_labels: bool = True
|
||
self._show_extremes: bool = True
|
||
self._shape: str = "round"
|
||
self._size: float = 300.0
|
||
self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
|
||
self._show_thickness: bool = False
|
||
self._thickness_heatmap:QImage | None = None
|
||
# Hover highlight: index of the marker under the pointer (-1 = none)
|
||
# and a 1.0->~1.5 grow factor driven by a QML Behavior for a smooth animation.
|
||
self._hovered_index: int = -1
|
||
self._hover_scale: float = 1.0
|
||
|
||
# Dark-theme color defaults (match Theme.qml tokens)
|
||
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
||
self._axis_color = QColor("#3A4D5C") # waferAxisColor (softBorder)
|
||
self._low_color = QColor("#5B9DF5") # sensorLow
|
||
self._in_range_color = QColor("#22C55E") # sensorInRange
|
||
self._high_color = QColor("#EF4444") # sensorHigh
|
||
self._text_color = QColor("#CBD5E1") # bodyColor
|
||
|
||
# Internal draw state
|
||
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
|
||
self._marker_r: int = 4
|
||
self._heatmap: QImage | None = None
|
||
# Size the markers/heatmap were last computed against. On first activation
|
||
# (e.g. a freshly-created Loader item) width()/height() can still be 0/stale
|
||
# when `sensors` is first set, computing degenerate marker positions that
|
||
# never get retried. paint() re-checks this every frame so geometry that
|
||
# settles after property assignment still gets picked up.
|
||
self._last_draw_size: int = -1
|
||
|
||
self.widthChanged.connect(self._on_resize)
|
||
self.heightChanged.connect(self._on_resize)
|
||
|
||
# ── Qt properties ────────────────────────────────────────────────────
|
||
|
||
@Property("QVariantList", notify=sensorsChanged)
|
||
def sensors(self) -> list:
|
||
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
||
|
||
@sensors.setter # type: ignore[no-redef]
|
||
def sensors(self, val: list) -> None:
|
||
self._sensors = [
|
||
Sensor(
|
||
label=d["label"],
|
||
x=float(d["x"]),
|
||
y=float(d["y"]),
|
||
side=d.get("side", "right"),
|
||
offset_x=float(d.get("offset_x", 0.0)),
|
||
offset_y=float(d.get("offset_y", 0.0))
|
||
)
|
||
for d in (val or [])
|
||
]
|
||
self._rebuild()
|
||
self.sensorsChanged.emit()
|
||
|
||
@Property("QVariantList", notify=valuesChanged)
|
||
def values(self) -> list:
|
||
return self._values
|
||
|
||
@values.setter # type: ignore[no-redef]
|
||
def values(self, val: list) -> None:
|
||
self._values = list(val or [])
|
||
self._rebuild_heatmap()
|
||
self.valuesChanged.emit()
|
||
self.update()
|
||
|
||
@Property("QVariantList", notify=bandsChanged)
|
||
def bands(self) -> list:
|
||
return self._bands
|
||
|
||
@bands.setter # type: ignore[no-redef]
|
||
def bands(self, val: list) -> None:
|
||
self._bands = list(val or [])
|
||
self.bandsChanged.emit()
|
||
self.update()
|
||
|
||
@Property(float, notify=targetChanged)
|
||
def target(self) -> float:
|
||
return self._target
|
||
|
||
@target.setter # type: ignore[no-redef]
|
||
def target(self, val: float) -> None:
|
||
self._target = float(val)
|
||
self._rebuild_heatmap()
|
||
self.targetChanged.emit()
|
||
self.update()
|
||
|
||
@Property(float, notify=marginChanged)
|
||
def margin(self) -> float:
|
||
return self._margin
|
||
|
||
@margin.setter # type: ignore[no-redef]
|
||
def margin(self, val: float) -> None:
|
||
self._margin = float(val)
|
||
self._rebuild_heatmap()
|
||
self.marginChanged.emit()
|
||
self.update()
|
||
|
||
@Property(float, notify=blendChanged)
|
||
def blend(self) -> float:
|
||
return self._blend
|
||
|
||
@blend.setter # type: ignore[no-redef]
|
||
def blend(self, val: float) -> None:
|
||
self._blend = max(0.0, min(1.0, float(val)))
|
||
if self._blend > 0 and self._heatmap is None:
|
||
self._rebuild_heatmap()
|
||
self.blendChanged.emit()
|
||
self.update()
|
||
|
||
@Property(bool, notify=showLabelsChanged)
|
||
def showLabels(self) -> bool:
|
||
return self._show_labels
|
||
|
||
@showLabels.setter # type: ignore[no-redef]
|
||
def showLabels(self, val: bool) -> None:
|
||
self._show_labels = bool(val)
|
||
self.showLabelsChanged.emit()
|
||
self.update()
|
||
|
||
@Property(bool, notify=showExtremesChanged)
|
||
def showExtremes(self) -> bool:
|
||
return self._show_extremes
|
||
|
||
@showExtremes.setter # type: ignore[no-redef]
|
||
def showExtremes(self, val: bool) -> None:
|
||
self._show_extremes = bool(val)
|
||
self.showExtremesChanged.emit()
|
||
self.update()
|
||
|
||
@Property(str, notify=shapeChanged)
|
||
def shape(self) -> str:
|
||
return self._shape
|
||
|
||
@shape.setter # type: ignore[no-redef]
|
||
def shape(self, val: str) -> None:
|
||
self._shape = str(val).lower()
|
||
self._rebuild()
|
||
self.shapeChanged.emit()
|
||
|
||
@Property(float, notify=sizeChanged)
|
||
def size(self) -> float:
|
||
return self._size
|
||
|
||
@size.setter # type: ignore[no-redef]
|
||
def size(self, val: float) -> None:
|
||
self._size = float(val)
|
||
self._rebuild()
|
||
self.sizeChanged.emit()
|
||
|
||
@Property("QVariantList", notify=thicknessChanged)
|
||
def thicknessData(self) -> list:
|
||
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
|
||
return self._thickness_data
|
||
|
||
@thicknessData.setter # type: ignore[no-redef]
|
||
def thicknessData(self, val:list) -> None:
|
||
self._thickness_data = [list(p) for p in (val or [])]
|
||
self._rebuild_thickness()
|
||
self.thicknessChanged.emit()
|
||
self.update()
|
||
|
||
@Property(bool, notify=thicknessChanged)
|
||
def hasThickness(self) -> bool:
|
||
return bool(self._thickness_data)
|
||
|
||
@Slot(str, result=str)
|
||
def loadThickness(self, file_path: str) -> str:
|
||
"""Load a customer thickness CSV; returns error message, '' on success."""
|
||
points, err = parse_thickness_csv(file_path)
|
||
if err:
|
||
return err
|
||
self._thickness_data = points
|
||
self._rebuild_thickness()
|
||
self.thicknessChanged.emit()
|
||
self.update()
|
||
return ""
|
||
self.update
|
||
|
||
@Property(bool, notify=showThicknessChanged)
|
||
def showThickness(self) -> bool:
|
||
return self._show_thickness
|
||
|
||
@showThickness.setter # type: ignore[no-redef]
|
||
def showThickness(self, val: bool) -> None:
|
||
self._show_thickness = bool(val)
|
||
self.showThicknessChanged.emit()
|
||
self.update()
|
||
|
||
@Property(int, notify=hoveredIndexChanged)
|
||
def hoveredIndex(self) -> int:
|
||
return self._hovered_index
|
||
|
||
@hoveredIndex.setter # type: ignore[no-redef]
|
||
def hoveredIndex(self, val: int) -> None:
|
||
val = int(val)
|
||
if val == self._hovered_index:
|
||
return
|
||
self._hovered_index = val
|
||
self.hoveredIndexChanged.emit()
|
||
self.update()
|
||
|
||
@Property(float, notify=hoverScaleChanged)
|
||
def hoverScale(self) -> float:
|
||
return self._hover_scale
|
||
|
||
@hoverScale.setter # type: ignore[no-redef]
|
||
def hoverScale(self, val: float) -> None:
|
||
self._hover_scale = float(val)
|
||
self.hoverScaleChanged.emit()
|
||
self.update()
|
||
|
||
# Colour properties — QML can bind these to Theme tokens
|
||
@Property(QColor, notify=colorsChanged)
|
||
def ringColor(self) -> QColor: return self._ring_color
|
||
@ringColor.setter # type: ignore[no-redef]
|
||
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
|
||
|
||
@Property(QColor, notify=colorsChanged)
|
||
def axisColor(self) -> QColor: return self._axis_color
|
||
@axisColor.setter # type: ignore[no-redef]
|
||
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
|
||
|
||
@Property(QColor, notify=colorsChanged)
|
||
def lowColor(self) -> QColor: return self._low_color
|
||
@lowColor.setter # type: ignore[no-redef]
|
||
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
|
||
|
||
@Property(QColor, notify=colorsChanged)
|
||
def inRangeColor(self) -> QColor: return self._in_range_color
|
||
@inRangeColor.setter # type: ignore[no-redef]
|
||
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
|
||
|
||
@Property(QColor, notify=colorsChanged)
|
||
def highColor(self) -> QColor: return self._high_color
|
||
@highColor.setter # type: ignore[no-redef]
|
||
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
|
||
|
||
@Property(QColor, notify=colorsChanged)
|
||
def textColor(self) -> QColor: return self._text_color
|
||
@textColor.setter # type: ignore[no-redef]
|
||
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
|
||
|
||
# ── slots ─────────────────────────────────────────────────────────────
|
||
|
||
@Slot(float, float, result=int)
|
||
def which_marker(self, x: float, y: float) -> int:
|
||
"""Return the sensor index nearest to (x, y) within marker radius, else -1."""
|
||
r = max(self._marker_r, 6)
|
||
for idx, (mx, my) in self._markers.items():
|
||
if abs(mx - x) <= r and abs(my - y) <= r:
|
||
return idx
|
||
return -1
|
||
|
||
# TODO P6.1: build batch export on top of this single-frame export
|
||
# THINKING: export_image() already does the hard part (grabToImage → PNG);
|
||
# batch export is just a loop over file_browser.files that loads each CSV
|
||
# through the existing wafer-map pipeline and calls this per file into a
|
||
# 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)
|
||
@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_path: Absolute path for the output PNG.
|
||
extra: Optional second footer line (live stream metrics).
|
||
|
||
Returns:
|
||
True on success, False on failure
|
||
"""
|
||
if not file_path or not self._values:
|
||
# No file loaded / no stream played yet — only the empty template
|
||
# would render, which isn't a meaningful export.
|
||
return False
|
||
try:
|
||
# Render synchronously via paint() into our own image —
|
||
# grabToImage() resolves on a later render frame, so its result
|
||
# is null when read immediately (the old silent failure).
|
||
w, h = int(self.width()), int(self.height())
|
||
if w <= 0 or h <= 0:
|
||
return False
|
||
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 lines:
|
||
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
|
||
painter.setPen(QColor("#CBD5E1"))
|
||
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
|
||
|
||
|
||
# ── internal ─────────────────────────────────────────────────────────
|
||
|
||
def _paint_extremes(self, painter: QPainter) -> None:
|
||
"""Ring the hottest (high color) and coldest (low color) markers."""
|
||
if not self._show_extremes or 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 points into a gray/orange heatmap QImage."""
|
||
if not self._thickness_data:
|
||
self._thickness_heatmap = None
|
||
return
|
||
ds = self._draw_size()
|
||
r_mm = self._wafer_radius_mm()
|
||
pts = np.array(self._thickness_data, dtype=float)
|
||
xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
|
||
try:
|
||
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
||
field = interpolate_field(
|
||
xs, ys, vs,
|
||
width=ds, height=ds,
|
||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||
)
|
||
except Exception:
|
||
self._thickness_heatmap = None
|
||
return
|
||
field = refine_field(field)
|
||
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||
# Clamp to the real data range so color only reflects measured values.
|
||
field = np.clip(field, vs.min(), vs.max())
|
||
if self._shape == "round":
|
||
alpha = ellipse_alpha(*field.shape)
|
||
else:
|
||
alpha = np.ones(field.shape)
|
||
# Color-range bounds from inside the wafer only — the unclipped field
|
||
# extrapolates wildly toward the square corners, which alpha hides but
|
||
# a corner-driven vmin/vmax would flatten the visible gradient.
|
||
inside = field[alpha >= 0.5]
|
||
vmin, vmax = inside.min(), inside.max()
|
||
span = vmax - vmin or 1.0
|
||
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
||
|
||
gh, gw = field.shape
|
||
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
|
||
rgb = np.zeros((gh, gw, 3), dtype=np.float32)
|
||
rgb[:, :, 0] = 0.5 + 0.5 * t
|
||
rgb[:, :, 1] = 0.5 + 0.15 * t
|
||
rgb[:, :, 2] = 0.5 - 0.5 * t
|
||
|
||
rgb = np.nan_to_num(rgb, nan=0.0)
|
||
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
|
||
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||
rgba[:, :, 3] = (alpha * 180).astype(np.uint8)
|
||
|
||
grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
|
||
self._thickness_heatmap = grid_img.scaled(
|
||
ds, ds,
|
||
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
|
||
def _on_resize(self) -> None:
|
||
self._rebuild()
|
||
|
||
def _rebuild(self) -> None:
|
||
self._compute_markers()
|
||
self._rebuild_heatmap()
|
||
self.update()
|
||
|
||
def _draw_size(self) -> int:
|
||
return max(1, int(min(self.width(), self.height())))
|
||
|
||
def _center(self) -> tuple[int, int]:
|
||
return int(self.width() / 2), int(self.height() / 2)
|
||
|
||
def _wafer_radius_mm(self) -> float:
|
||
"""Radius of the wafer bounding circle in mm (5% padding beyond outermost sensor)."""
|
||
if not self._sensors:
|
||
return 150.0
|
||
r = max(math.hypot(s.x, s.y) for s in self._sensors)
|
||
return r * 1.05
|
||
|
||
# TODO P6.2: reuse this for the edge-to-center delta metric
|
||
# THINKING: radial_metrics.py (new) needs the same "which sensors are near
|
||
# the edge vs. near the center" bucketing this function already computes
|
||
# for ring-line drawing — do NOT re-derive ring boundaries separately, this
|
||
# already handles round vs. square wafer shapes correctly (see family_spec.py
|
||
# square-wafer work). Outermost group ≈ edge sensors, innermost ≈ center.
|
||
# See docs/pending/alpha-release-polish-plan.md §6.2.
|
||
def _sensor_ring_radii_mm(self) -> list[float]:
|
||
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||
if not self._sensors:
|
||
r = self._wafer_radius_mm()
|
||
return [r * f for f in (0.25, 0.50, 0.75, 1.0)]
|
||
# Cluster radii that are within 2 mm of each other into one ring; skip center point.
|
||
radii = sorted(r for r in {math.hypot(s.x, s.y) for s in self._sensors} if r > 1.0)
|
||
groups: list[float] = []
|
||
for r in radii:
|
||
if not groups or r - groups[-1] > 2.0:
|
||
groups.append(r)
|
||
else:
|
||
groups[-1] = (groups[-1] + r) / 2 # merge close values
|
||
# Always include the outer boundary ring so the wafer circle is drawn.
|
||
outer = self._wafer_radius_mm()
|
||
if not groups or outer - groups[-1] > 2.0:
|
||
groups.append(outer)
|
||
return groups
|
||
|
||
def _scale(self, ds: int, r_mm: float) -> float:
|
||
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
|
||
return (ds / 2 - 24) / r_mm
|
||
|
||
def _to_px(self, x_mm: float, y_mm: float, cx: int, cy: int, scale: float) -> tuple[int, int]:
|
||
"""Center-origin mm → pixel (top-left origin). Y is flipped."""
|
||
return cx + int(x_mm * scale), cy - int(y_mm * scale)
|
||
|
||
def _compute_markers(self) -> None:
|
||
ds = self._draw_size()
|
||
r_mm = self._wafer_radius_mm()
|
||
sc = self._scale(ds, r_mm)
|
||
cx, cy = self._center()
|
||
self._marker_r = max(3, ds // 70)
|
||
self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
|
||
for i, s in enumerate(self._sensors)}
|
||
|
||
def _rebuild_heatmap(self) -> None:
|
||
if not self._sensors or not self._values or self._blend == 0.0:
|
||
self._heatmap = None
|
||
return
|
||
ds = self._draw_size()
|
||
r_mm = self._wafer_radius_mm()
|
||
xs = np.array([s.x for s in self._sensors])
|
||
ys = np.array([s.y for s in self._sensors])
|
||
vs = np.array(self._values[:len(self._sensors)], dtype=float)
|
||
if len(vs) < len(self._sensors):
|
||
self._heatmap = None
|
||
return
|
||
try:
|
||
# No round_clip: refine the smooth unclipped field, then mask with an
|
||
# analytic anti-aliased ellipse (a coarse-grid clip mask upscales jagged).
|
||
field = interpolate_field(
|
||
xs, ys, vs,
|
||
width=ds, height=ds,
|
||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||
)
|
||
except Exception:
|
||
self._heatmap = None
|
||
return
|
||
field = refine_field(field)
|
||
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||
# Clamp to the real data range so color only reflects measured values.
|
||
field = np.clip(field, vs.min(), vs.max())
|
||
if self._shape == "round":
|
||
alpha = ellipse_alpha(*field.shape)
|
||
else:
|
||
alpha = np.ones(field.shape)
|
||
grid_img = self._field_to_qimage(field, alpha)
|
||
self._heatmap = grid_img.scaled(
|
||
ds, ds,
|
||
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
|
||
def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
|
||
"""Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
|
||
lo_b = self._target - self._margin
|
||
hi_b = self._target + self._margin
|
||
span = hi_b - lo_b or 1.0
|
||
# t: 0 = lo_b, 1 = hi_b (clipped)
|
||
t = np.clip((field - lo_b) / span, 0.0, 1.0)
|
||
|
||
def c(q: QColor) -> np.ndarray:
|
||
return np.array([q.redF(), q.greenF(), q.blueF()], dtype=np.float32)
|
||
|
||
lo_c = c(self._low_color)
|
||
mid_c = c(self._in_range_color)
|
||
hi_c = c(self._high_color)
|
||
|
||
t2 = t * 2 # 0→2 across full range
|
||
|
||
lower = t <= 0.5
|
||
t_lo = np.clip(t2, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in lower half
|
||
t_hi = np.clip(t2 - 1.0, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in upper half
|
||
|
||
rgb = np.where(
|
||
lower[:, :, np.newaxis],
|
||
lo_c * (1 - t_lo) + mid_c * t_lo,
|
||
mid_c * (1 - t_hi) + hi_c * t_hi,
|
||
)
|
||
rgb = np.nan_to_num(rgb, nan=0.0)
|
||
gh, gw = field.shape
|
||
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
|
||
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||
# alpha is a continuous 0..1 coverage mask (not a hard isfinite cutoff) so the
|
||
# round-clip boundary anti-aliases instead of showing coarse-grid steps.
|
||
rgba[:, :, 3] = (alpha * 210).astype(np.uint8)
|
||
|
||
return QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
|
||
|
||
# ── paint ─────────────────────────────────────────────────────────────
|
||
|
||
def paint(self, painter: QPainter) -> None:
|
||
ds = self._draw_size()
|
||
if ds != self._last_draw_size:
|
||
# Geometry settled (or changed) since markers/heatmap were last built
|
||
# against a stale size — recompute now, at actual paint time.
|
||
self._last_draw_size = ds
|
||
self._compute_markers()
|
||
self._rebuild_heatmap()
|
||
self._rebuild_thickness()
|
||
|
||
r_px = int(ds / 2 - 4)
|
||
cx, cy = self._center()
|
||
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
self._paint_template(painter, cx, cy, r_px)
|
||
|
||
if self._heatmap and self._blend > 0.0:
|
||
painter.setOpacity(self._blend)
|
||
painter.drawImage(cx - self._heatmap.width() // 2,
|
||
cy - self._heatmap.height() // 2, self._heatmap)
|
||
painter.setOpacity(1.0)
|
||
|
||
if self._show_thickness and self._thickness_heatmap:
|
||
painter.setOpacity(0.4)
|
||
painter.drawImage(cx - self._thickness_heatmap.width() // 2,
|
||
cy - self._thickness_heatmap.height()//2, self._thickness_heatmap)
|
||
painter.setOpacity(1.0)
|
||
|
||
self._paint_markers(painter)
|
||
self._paint_extremes(painter)
|
||
|
||
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||
ds = self._draw_size()
|
||
r_mm = self._wafer_radius_mm()
|
||
sc = self._scale(ds, r_mm)
|
||
|
||
if self._shape == "square":
|
||
# Draw square boundary (thick pen)
|
||
border_pen = QPen(self._ring_color, 2, Qt.PenStyle.SolidLine)
|
||
painter.setPen(border_pen)
|
||
half_size_px = int(self._size / 2 * sc)
|
||
painter.drawRect(cx - half_size_px, cy - half_size_px, 2 * half_size_px, 2 * half_size_px)
|
||
|
||
# Crosshair axes
|
||
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
|
||
painter.setPen(axis_pen)
|
||
painter.drawLine(cx, cy - half_size_px, cx, cy + half_size_px)
|
||
painter.drawLine(cx - half_size_px, cy, cx + half_size_px, cy)
|
||
|
||
# Draw concentric square guide lines at the distinct radii of sensor rings
|
||
grid_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
|
||
painter.setPen(grid_pen)
|
||
for ring_r_mm in self._sensor_ring_radii_mm()[:-1]: # exclude the outermost border
|
||
rr = max(1, int(ring_r_mm * sc))
|
||
painter.drawRect(cx - rr, cy - rr, 2 * rr, 2 * rr)
|
||
else:
|
||
# Concentric rings
|
||
ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
|
||
painter.setPen(ring_pen)
|
||
for ring_r_mm in self._sensor_ring_radii_mm():
|
||
rr = max(1, int(ring_r_mm * sc))
|
||
painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr)
|
||
|
||
# Crosshair axes
|
||
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
|
||
painter.setPen(axis_pen)
|
||
painter.drawLine(cx, cy - r_px, cx, cy + r_px)
|
||
painter.drawLine(cx - r_px, cy, cx + r_px, cy)
|
||
|
||
# Top notch triangle (wafer orientation marker)
|
||
nw = max(6, ds // 25)
|
||
nh = max(4, ds // 35)
|
||
notch = QPolygon([
|
||
QPoint(cx, cy - r_px),
|
||
QPoint(cx - nw // 2, cy - r_px + nh),
|
||
QPoint(cx + nw // 2, cy - r_px + nh),
|
||
])
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QBrush(self._axis_color))
|
||
painter.drawPolygon(notch)
|
||
|
||
def _paint_markers(self, painter: QPainter) -> None:
|
||
r = self._marker_r
|
||
|
||
# Scale font size based on the number of sensors to prevent overlap on dense wafers
|
||
num_sensors = len(self._sensors)
|
||
font_scale = 1.0
|
||
if num_sensors > 60:
|
||
font_scale = 0.7 # reduce font size by 30% for dense wafers
|
||
elif num_sensors > 40:
|
||
font_scale = 0.85
|
||
|
||
id_font = QFont()
|
||
id_font.setPointSize(max(9, int(r * font_scale)))
|
||
id_font.setBold(True)
|
||
|
||
temp_font = QFont()
|
||
temp_font.setPointSize(max(9, int(r * font_scale)))
|
||
|
||
band_color = {
|
||
"in_range": self._in_range_color,
|
||
"high": self._high_color,
|
||
"low": self._low_color,
|
||
}
|
||
|
||
painter.setFont(id_font)
|
||
id_fm = painter.fontMetrics()
|
||
id_line_h = id_fm.height()
|
||
id_ascent = id_fm.ascent()
|
||
|
||
painter.setFont(temp_font)
|
||
temp_fm = painter.fontMetrics()
|
||
temp_line_h = temp_fm.height()
|
||
temp_ascent = temp_fm.ascent()
|
||
|
||
for i, s in enumerate(self._sensors):
|
||
if i not in self._markers:
|
||
continue
|
||
px, py = self._markers[i]
|
||
color = band_color.get(
|
||
self._bands[i] if i < len(self._bands) else "in_range",
|
||
self._in_range_color,
|
||
)
|
||
is_hovered = i == self._hovered_index
|
||
marker_r = int(round(r * self._hover_scale)) if is_hovered else r
|
||
|
||
if is_hovered:
|
||
# Soft glow ring behind the marker, growing with hoverScale.
|
||
glow_r = int(round(marker_r * 1.9))
|
||
glow_color = QColor(color)
|
||
glow_color.setAlpha(70)
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.setBrush(QBrush(glow_color))
|
||
painter.drawEllipse(px - glow_r, py - glow_r, 2 * glow_r, 2 * glow_r)
|
||
|
||
# Filled circle with thin dark outline for contrast over heatmap
|
||
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
|
||
painter.setBrush(QBrush(color))
|
||
painter.drawEllipse(px - marker_r, py - marker_r, 2 * marker_r, 2 * marker_r)
|
||
|
||
if self._show_labels:
|
||
has_temp = i < len(self._values)
|
||
|
||
# Fetch text alignment side and offsets
|
||
side = getattr(s, "side", "right").lower()
|
||
ox = getattr(s, "offset_x", 0.0) * r
|
||
oy = getattr(s, "offset_y", 0.0) * r
|
||
|
||
id_text = s.label
|
||
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
||
|
||
id_w = id_fm.horizontalAdvance(id_text)
|
||
temp_w = temp_fm.horizontalAdvance(temp_text) if has_temp else 0
|
||
text_w = max(id_w, temp_w)
|
||
|
||
# Height of the 1 or 2-line text block
|
||
text_h = (id_line_h + temp_line_h) if has_temp else id_line_h
|
||
|
||
# Calculate box top-left (lx, ly) relative to dot center (px, py)
|
||
gap = 3
|
||
if side == "left":
|
||
lx = px - r - gap - text_w - ox
|
||
ly = py - text_h // 2 + oy
|
||
elif side == "top":
|
||
lx = px - text_w // 2 + ox
|
||
ly = py - r - gap - text_h - oy
|
||
elif side == "bottom":
|
||
lx = px - text_w // 2 + ox
|
||
ly = py + r + gap + oy
|
||
else: # "right" or default
|
||
lx = px + r + gap + ox
|
||
ly = py - text_h // 2 + oy
|
||
|
||
# Draw Sensor ID (first line)
|
||
painter.setFont(id_font)
|
||
painter.setPen(QPen(self._text_color))
|
||
y1 = ly + id_ascent
|
||
if side in ("top", "bottom"):
|
||
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) # type: ignore[arg-type]
|
||
elif side == "left":
|
||
painter.drawText(lx + (text_w - id_w), y1, id_text) # type: ignore[arg-type]
|
||
else:
|
||
painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
|
||
|
||
# Draw Temperature (second line)
|
||
if has_temp:
|
||
painter.setFont(temp_font)
|
||
painter.setPen(QPen(color))
|
||
y2 = ly + id_line_h + temp_ascent
|
||
if side in ("top", "bottom"):
|
||
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) # type: ignore[arg-type]
|
||
elif side == "left":
|
||
painter.drawText(lx + (text_w - temp_w), y2, temp_text) # type: ignore[arg-type]
|
||
else:
|
||
painter.drawText(lx, y2, temp_text) # type: ignore[arg-type]
|