refactor: optimize RBF heatmap performance by decoupling grid resolution from viewport size and update UI file handling.
This commit is contained in:
@@ -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 ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user