refactor: optimize RBF heatmap performance by decoupling grid resolution from viewport size and update UI file handling.

This commit is contained in:
jack
2026-07-06 21:02:22 -07:00
parent 015642eea0
commit 799155f249
6 changed files with 119 additions and 70 deletions
+12 -1
View File
@@ -199,9 +199,20 @@ ColumnLayout {
} catch (e) {} } catch (e) {}
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile; return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
} }
// "live_<serial>_<timestamp>.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 visible: matchesFilter
height: matchesFilter ? implicitHeight : 0 height: matchesFilter ? implicitHeight : 0
enabled: !isLiveFile
opacity: isLiveFile ? 0.45 : 1.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
onClicked: streamController.loadFile(modelData.fileName) onClicked: streamController.loadFile(modelData.fileName)
@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import logging import logging
import time import time
from pathlib import Path
from typing import Optional from typing import Optional
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
@@ -258,8 +259,6 @@ class SessionController(QObject):
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def loadFile(self, file_path: str) -> None: def loadFile(self, file_path: str) -> None:
from pathlib import Path
from pygui.backend.data.data_records import ( from pygui.backend.data.data_records import (
is_official_csv, is_official_csv,
read_data_records, read_data_records,
@@ -348,8 +347,6 @@ class SessionController(QObject):
@slot_error_boundary @slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None: def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result.""" """Run DTW comparison between two CSV files and emit result."""
from pathlib import Path
from pygui.backend.comparison import compare_runs from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv 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 average_clusters(edited, getattr(self, "_active_clusters", []))
return edited 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: def _emit_current(self) -> None:
frame = self._player.current() frame = self._player.current()
if frame is None: if frame is None:
+1 -1
View File
@@ -12,7 +12,7 @@ from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder: class CsvRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None self._path: Optional[str] = None
@property @property
+47 -12
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from scipy.interpolate import RBFInterpolator from scipy.interpolate import RBFInterpolator
from scipy.ndimage import zoom as _ndi_zoom
try: try:
import cupy as _cupy # type: ignore import cupy as _cupy # type: ignore
@@ -17,14 +18,18 @@ except Exception:
_KERNEL = "thin_plate_spline" _KERNEL = "thin_plate_spline"
_SMOOTHING = 0.0 _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( def interpolate_field(
xs: np.ndarray, xs: np.ndarray,
ys: np.ndarray, ys: np.ndarray,
@@ -35,26 +40,30 @@ def interpolate_field(
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
round_clip: bool = False, round_clip: bool = False,
) -> np.ndarray: ) -> 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: Args:
xs, ys: sensor positions in mm (1-D arrays, length N) xs, ys: sensor positions in mm (1-D arrays, length N)
vs: sensor values (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 extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
round_clip: if True, pixels outside the inscribed ellipse become NaN round_clip: if True, pixels outside the inscribed ellipse become NaN
""" """
coords = np.column_stack([xs, ys]) coords = np.column_stack([xs, ys])
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING) 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 xmin, xmax, ymin, ymax = extent
gx = np.linspace(xmin, xmax, width) gx = np.linspace(xmin, xmax, grid_w)
gy = np.linspace(ymin, ymax, height) gy = np.linspace(ymin, ymax, grid_h)
grid_x, grid_y = np.meshgrid(gx, gy) grid_x, grid_y = np.meshgrid(gx, gy)
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()]) flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later # 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: if round_clip:
cx = (xmin + xmax) / 2 cx = (xmin + xmax) / 2
@@ -66,3 +75,29 @@ def interpolate_field(
return field.astype(np.float64) 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)
@@ -27,7 +27,11 @@ from PySide6.QtGui import (
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem 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 from pygui.backend.wafer.zwafer_models import Sensor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -333,33 +337,46 @@ class WaferMapItem(QQuickPaintedItem):
self._thickness_heatmap = None self._thickness_heatmap = None
return return
try: try:
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
field = interpolate_field( field = interpolate_field(
xs, ys, vs, xs, ys, vs,
width=ds, height=ds, width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm), extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
) )
except Exception: except Exception:
self._thickness_heatmap = None self._thickness_heatmap = None
return return
# Gray/orange colormap: map field range 0→1 to gray→orange field = refine_field(field)
vmin, vmax = np.nanmin(field), np.nanmax(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 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) 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) # 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[:, :, 0] = 0.5 + 0.5 * t
rgb[:, :, 1] = 0.5 + 0.15 * t rgb[:, :, 1] = 0.5 + 0.15 * t
rgb[:, :, 2] = 0.5 - 0.5 * t rgb[:, :, 2] = 0.5 - 0.5 * t
rgb = np.nan_to_num(rgb, nan=0.0) 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] = (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 = ( grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy() self._thickness_heatmap = grid_img.scaled(
ds, ds,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
) )
def _on_resize(self) -> None: 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) self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
for i, s in enumerate(self._sensors)} 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: def _rebuild_heatmap(self) -> None:
if not self._sensors or not self._values or self._blend == 0.0: if not self._sensors or not self._values or self._blend == 0.0:
self._heatmap = None self._heatmap = None
@@ -448,19 +456,30 @@ class WaferMapItem(QQuickPaintedItem):
self._heatmap = None self._heatmap = None
return return
try: 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( field = interpolate_field(
xs, ys, vs, xs, ys, vs,
width=ds, height=ds, width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm), extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
) )
except Exception: except Exception:
self._heatmap = None self._heatmap = None
return 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: def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
"""Apply a band-aware tri-color gradient → RGBA QImage.""" """Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
lo_b = self._target - self._margin lo_b = self._target - self._margin
hi_b = self._target + self._margin hi_b = self._target + self._margin
span = hi_b - lo_b or 1.0 span = hi_b - lo_b or 1.0
@@ -485,14 +504,15 @@ class WaferMapItem(QQuickPaintedItem):
lo_c * (1 - t_lo) + mid_c * t_lo, lo_c * (1 - t_lo) + mid_c * t_lo,
mid_c * (1 - t_hi) + hi_c * t_hi, 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) 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] = (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 ───────────────────────────────────────────────────────────── # ── paint ─────────────────────────────────────────────────────────────
@@ -601,6 +621,16 @@ class WaferMapItem(QQuickPaintedItem):
"low": self._low_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): for i, s in enumerate(self._sensors):
if i not in self._markers: if i not in self._markers:
continue continue
@@ -634,23 +664,6 @@ class WaferMapItem(QQuickPaintedItem):
ox = getattr(s, "offset_x", 0.0) * r ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 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 id_text = s.label
temp_text = f"{self._values[i]:.2f}" if has_temp else "" temp_text = f"{self._values[i]:.2f}" if has_temp else ""
+1
View File
@@ -235,6 +235,7 @@ def test_recording_toggle(controller, tmp_path):
content = open(csv_path).read() content = open(csv_path).read()
assert "Wafer ID=SN123" in content assert "Wafer ID=SN123" in content
def test_resync_count_default(controller): def test_resync_count_default(controller):
# No active reader -> zero # No active reader -> zero
assert controller.resyncCount == 0 assert controller.resyncCount == 0