Files
pyGUI/src/pygui/backend/visualization/wafer_map_item.py
T
jack 6003bde84d feat(import): Z-wafer CSV import button with result banner
- 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
2026-07-12 16:54:24 -07:00

839 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]