"""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]