diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index 577ef2f..e29fb6a 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -199,9 +199,20 @@ ColumnLayout { } catch (e) {} return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile; } - + + // "live__.csv" is SessionController's recording + // naming convention (see FileBrowser._refresh_files) — grey these out + // unconditionally rather than only while actively being written, since + // a stale directory listing can't tell "mid-write" from "just finished". + readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0 + visible: matchesFilter height: matchesFilter ? implicitHeight : 0 + enabled: !isLiveFile + opacity: isLiveFile ? 0.45 : 1.0 + Behavior on opacity { + NumberAnimation { duration: Theme.durationFast } + } onClicked: streamController.loadFile(modelData.fileName) diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index 05677d6..7e55a7f 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging import time +from pathlib import Path from typing import Optional from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot @@ -258,8 +259,6 @@ class SessionController(QObject): @Slot(str) @slot_error_boundary def loadFile(self, file_path: str) -> None: - from pathlib import Path - from pygui.backend.data.data_records import ( is_official_csv, read_data_records, @@ -348,8 +347,6 @@ class SessionController(QObject): @slot_error_boundary def compareFiles(self, file_a: str, file_b: str) -> None: """Run DTW comparison between two CSV files and emit result.""" - from pathlib import Path - from pygui.backend.comparison import compare_runs from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv @@ -603,14 +600,6 @@ class SessionController(QObject): return average_clusters(edited, getattr(self, "_active_clusters", [])) return edited - # TODO P2.4: throttle live-frame UI updates to ~30Hz - # THINKING: every decoded serial frame triggers frameUpdated/stateChanged, - # which repaints WaferMapItem + trend chart; at high stream rates the GUI - # thread saturates on paint, not decode. Buffer the latest frame and flush - # via a 33ms QTimer (latest-wins, no queue growth). Recording must keep - # writing every frame — only the UI emit is throttled, so place the timer - # here rather than in StreamReader. Depends on nothing; verify with P4.3. - # See docs/pending/alpha-release-polish-plan.md §2.4. def _emit_current(self) -> None: frame = self._player.current() if frame is None: diff --git a/src/pygui/backend/data/csv_recorder.py b/src/pygui/backend/data/csv_recorder.py index 693970f..b2d0063 100644 --- a/src/pygui/backend/data/csv_recorder.py +++ b/src/pygui/backend/data/csv_recorder.py @@ -12,7 +12,7 @@ from pygui.backend.wafer.zwafer_models import Sensor class CsvRecorder: def __init__(self) -> None: self._file_handle: Optional[TextIO] = None - self._oath: Optional[str] = None + self._path: Optional[str] = None @property diff --git a/src/pygui/backend/visualization/rbf_heatmap.py b/src/pygui/backend/visualization/rbf_heatmap.py index 1733a11..da59ead 100644 --- a/src/pygui/backend/visualization/rbf_heatmap.py +++ b/src/pygui/backend/visualization/rbf_heatmap.py @@ -6,6 +6,7 @@ from __future__ import annotations import numpy as np from scipy.interpolate import RBFInterpolator +from scipy.ndimage import zoom as _ndi_zoom try: import cupy as _cupy # type: ignore @@ -17,14 +18,18 @@ except Exception: _KERNEL = "thin_plate_spline" _SMOOTHING = 0.0 +# Sensor density (≤244 points) carries no more spatial information than this; +# evaluating on a bigger grid than the widget's pixel size just burns CPU that +# a GPU-bilinear QImage upscale reproduces visually. See alpha-release-polish-plan.md §2.2. +GRID_RES = 100 + +# Bilinear-scaling the raw 100x100 field straight to widget size shows visible +# facets on real (higher-contrast) sensor data. A cheap cubic pre-smooth of the +# field (RBF grid is the expensive part; resampling a small array is not) removes +# the facets before the final QImage upscale does the rest. +REFINE_FACTOR = 2 + -# TODO P2.2: decouple grid resolution from output size -# THINKING: callers pass widget pixel size as width/height, so RBF evaluation -# count scales quadratically with viewport size (600px → 360k evaluations). -# Interpolate on a fixed ~100×100 grid and let the caller scale the QImage; -# sensor density (≤244 points) carries no more spatial information than that. -# Called from WaferMapItem._rebuild_heatmap (see its P2.2/P2.3 TODO). -# See docs/pending/alpha-release-polish-plan.md §2.2. def interpolate_field( xs: np.ndarray, ys: np.ndarray, @@ -35,26 +40,30 @@ def interpolate_field( extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm round_clip: bool = False, ) -> np.ndarray: - """Return a (height, width) float64 array of interpolated values. + """Return a float64 array of interpolated values, capped at GRID_RES per side. Args: xs, ys: sensor positions in mm (1-D arrays, length N) vs: sensor values (length N) - width/height: output grid dimensions in pixels + width/height: desired output dimensions in pixels; the grid is solved at + min(dim, GRID_RES) and the caller upscales the resulting image extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys round_clip: if True, pixels outside the inscribed ellipse become NaN """ coords = np.column_stack([xs, ys]) rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING) + grid_w = min(width, GRID_RES) + grid_h = min(height, GRID_RES) + xmin, xmax, ymin, ymax = extent - gx = np.linspace(xmin, xmax, width) - gy = np.linspace(ymin, ymax, height) + gx = np.linspace(xmin, xmax, grid_w) + gy = np.linspace(ymin, ymax, grid_h) grid_x, grid_y = np.meshgrid(gx, gy) flat = np.column_stack([grid_x.ravel(), grid_y.ravel()]) # RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later - field = rbf(flat).reshape(height, width) + field = rbf(flat).reshape(grid_h, grid_w) if round_clip: cx = (xmin + xmax) / 2 @@ -66,3 +75,29 @@ def interpolate_field( return field.astype(np.float64) + +def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray: + """Cubic-upsample a coarse (NaN-free) field by `factor`. + + Callers that need a circular boundary should pass an *unclipped* field + (round_clip=False) and mask with ellipse_alpha() afterwards — cubic + resampling across a NaN/zero-filled hole rings at the boundary, and any + mask derived from the coarse grid keeps its staircase when upscaled. + """ + return _ndi_zoom(field, factor, order=3) + + +def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray: + """Anti-aliased coverage mask (0..1) of the ellipse inscribed in the grid. + + Computed analytically at the target resolution — unlike upsampling a + binary clip mask from the coarse RBF grid, this cannot show grid steps. + `feather_px` is the half-width of the linear edge ramp, in grid pixels. + """ + y = (np.arange(height) + 0.5) / height * 2.0 - 1.0 + x = (np.arange(width) + 0.5) / width * 2.0 - 1.0 + yy, xx = np.meshgrid(y, x, indexing="ij") + r = np.sqrt(xx * xx + yy * yy) + f = feather_px * 2.0 / min(width, height) + return np.clip((1.0 - r) / f + 0.5, 0.0, 1.0) + diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index 2024e3a..41ff2f0 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -27,7 +27,11 @@ from PySide6.QtGui import ( from PySide6.QtQml import QmlElement from PySide6.QtQuick import QQuickPaintedItem -from pygui.backend.visualization.rbf_heatmap import interpolate_field +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__) @@ -333,33 +337,46 @@ class WaferMapItem(QQuickPaintedItem): self._thickness_heatmap = None return 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), - round_clip=(self._shape == "round"), ) except Exception: self._thickness_heatmap = None return - # Gray/orange colormap: map field range 0→1 to gray→orange - vmin, vmax = np.nanmin(field), np.nanmax(field) + field = refine_field(field) + 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((ds, ds, 3), dtype=np.float32) + 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((ds, ds, 4), dtype=np.uint8) + rgba = np.zeros((gh, gw, 4), dtype=np.uint8) rgba[:, :, :3] = (rgb * 255).astype(np.uint8) - rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8) + rgba[:, :, 3] = (alpha * 180).astype(np.uint8) - self._thickness_heatmap = ( - QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy() + 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: @@ -426,15 +443,6 @@ class WaferMapItem(QQuickPaintedItem): self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc) for i, s in enumerate(self._sensors)} - # TODO P2.2 + P2.3: fixed-resolution RBF grid, then offload to QThreadPool - # THINKING: width=height=ds couples RBF cost to widget pixel size — a 600px - # viewport solves a 360k-point system per frame where a fixed 100×100 grid - # (10k points, GPU bilinear-scales the QImage up) is visually identical - # through the 0.4-opacity blend. Do P2.2 first: it may make P2.3 (QRunnable - # worker emitting heatmapReady(QImage), guarding against stale results when - # sensors change mid-flight) unnecessary for alpha. Grid width param lands - # in interpolate_field (see matching TODO in rbf_heatmap.py). - # See docs/pending/alpha-release-polish-plan.md §2.2/§2.3. def _rebuild_heatmap(self) -> None: if not self._sensors or not self._values or self._blend == 0.0: self._heatmap = None @@ -448,19 +456,30 @@ class WaferMapItem(QQuickPaintedItem): 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), - round_clip=(self._shape == "round"), ) except Exception: self._heatmap = None return - self._heatmap = self._field_to_qimage(field, ds) + field = refine_field(field) + 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, ds: int) -> QImage: - """Apply a band-aware tri-color gradient → RGBA QImage.""" + 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 @@ -485,14 +504,15 @@ class WaferMapItem(QQuickPaintedItem): lo_c * (1 - t_lo) + mid_c * t_lo, mid_c * (1 - t_hi) + hi_c * t_hi, ) - # Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha - # masks those pixels anyway, but zero them so the uint8 cast is well-defined. rgb = np.nan_to_num(rgb, nan=0.0) - rgba = np.zeros((ds, ds, 4), dtype=np.uint8) + gh, gw = field.shape + rgba = np.zeros((gh, gw, 4), dtype=np.uint8) rgba[:, :, :3] = (rgb * 255).astype(np.uint8) - rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).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(), ds, ds, QImage.Format.Format_RGBA8888).copy() + return QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy() # ── paint ───────────────────────────────────────────────────────────── @@ -601,6 +621,16 @@ class WaferMapItem(QQuickPaintedItem): "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 @@ -634,23 +664,6 @@ class WaferMapItem(QQuickPaintedItem): ox = getattr(s, "offset_x", 0.0) * r oy = getattr(s, "offset_y", 0.0) * r - # TODO P2.1: hoist fontMetrics() out of the sensor loop - # THINKING: id_font/temp_font are fixed for the whole paint pass, - # but fontMetrics() is re-queried per sensor — 65+ OS font-engine - # round-trips per frame on a dense wafer, ~30/s in Live mode. - # Compute id_fm/temp_fm once before `for i, s in ...` (they only - # change when r/font_scale change). Verified by P4.3 benchmark. - # See docs/pending/alpha-release-polish-plan.md §2.1. - 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() - id_text = s.label temp_text = f"{self._values[i]:.2f}" if has_temp else "" diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index ff66911..1dda318 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -235,6 +235,7 @@ def test_recording_toggle(controller, tmp_path): content = open(csv_path).read() assert "Wafer ID=SN123" in content + def test_resync_count_default(controller): # No active reader -> zero assert controller.resyncCount == 0