refactor: reorganize backend modules into sub-packages for models, data, visualization, wafer, and controllers
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# ===== Visualization Sub-package =====
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
|
||||
|
||||
__all__ = [
|
||||
"WaferMapItem", "GraphView",
|
||||
"interpolate_field",
|
||||
"ContourLine", "ContourSegment",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
|
||||
# ===== Single Contour Segment =====
|
||||
@dataclass
|
||||
class ContourSegment:
|
||||
start_x: float
|
||||
start_y: float
|
||||
end_x: float
|
||||
end_y: float
|
||||
|
||||
@property
|
||||
def start(self):
|
||||
return (self.start_x, self.start_y)
|
||||
|
||||
@property
|
||||
def end(self):
|
||||
return (self.end_x, self.end_y)
|
||||
|
||||
|
||||
# ===== Contour Line =====
|
||||
@dataclass
|
||||
class ContourLine:
|
||||
level: float
|
||||
segments: List[ContourSegment] = field(default_factory=list)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""pyqtgraph PlotWidget wrapper for embedding in QML.
|
||||
|
||||
Exposes a QWidget with a pyqtgraph PlotWidget that can be displayed
|
||||
via QML's `import QtWidgets` or `QtWidgets.QWidget` integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Property, Signal, Slot
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Import pyqtgraph after Qt is initialized
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import PlotWidget
|
||||
|
||||
|
||||
class GraphView(QObject):
|
||||
"""QML-exposed controller for a pyqtgraph line chart.
|
||||
|
||||
Accepts sensor temperature data (list of lists) and renders
|
||||
each sensor as a separate line series.
|
||||
"""
|
||||
|
||||
# ---- signals ----
|
||||
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._plot_widget: Optional[PlotWidget] = None
|
||||
self._plot_window: Optional[QWidget] = None
|
||||
self._series: list[Any] = []
|
||||
self._sensor_names: list[str] = []
|
||||
|
||||
@Property(object, notify=dataReady)
|
||||
def plotWidget(self) -> Any:
|
||||
"""Return the QWidget hosting the pyqtgraph PlotWidget for QML embedding."""
|
||||
return self._plot_window
|
||||
|
||||
@Slot()
|
||||
def createPlotWidget(self, parent_widget: Optional[QWidget] = None) -> None:
|
||||
"""Create and return a QWidget containing a pyqtgraph PlotWidget.
|
||||
|
||||
Args:
|
||||
parent_widget: Optional parent widget.
|
||||
"""
|
||||
pg.setConfigOption("background", "default")
|
||||
pg.setConfigOption("foreground", "default")
|
||||
|
||||
self._plot_window = QWidget(parent=parent_widget)
|
||||
self._plot_widget = PlotWidget()
|
||||
self._plot_widget.setBackground("default")
|
||||
|
||||
from PySide6.QtWidgets import QVBoxLayout
|
||||
layout = QVBoxLayout(self._plot_window)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self._plot_widget)
|
||||
|
||||
# Set axis labels
|
||||
self._plot_widget.setLabel("left", "Temperature", units="°C")
|
||||
self._plot_widget.setLabel("bottom", "Measurement Interval")
|
||||
self._plot_widget.setTitle("Sensor Temperature Over Time")
|
||||
|
||||
@Slot(str, str)
|
||||
def updateChart(self, sensor_names_str: str, series_data_str: str) -> None:
|
||||
"""Update the chart with sensor data.
|
||||
|
||||
Args:
|
||||
sensor_names_str: Comma-separated sensor names (e.g. "Sensor1,Sensor2").
|
||||
series_data_str: JSON-like string of nested lists for each sensor's values.
|
||||
"""
|
||||
import json
|
||||
|
||||
if not self._plot_widget:
|
||||
log.warning("PlotWidget not created yet")
|
||||
return
|
||||
|
||||
try:
|
||||
sensor_names = [s.strip() for s in sensor_names_str.split(",") if s.strip()]
|
||||
series_data = json.loads(series_data_str)
|
||||
except (json.JSONDecodeError, AttributeError) as exc:
|
||||
log.error("Failed to parse chart data: %s", exc)
|
||||
return
|
||||
|
||||
# Clear existing series
|
||||
self._plot_widget.clear()
|
||||
self._series = []
|
||||
|
||||
if not series_data:
|
||||
return
|
||||
|
||||
# Determine Y-axis range from all data
|
||||
all_values = []
|
||||
for sensor_values in series_data:
|
||||
for v in sensor_values:
|
||||
try:
|
||||
all_values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if all_values:
|
||||
y_min = min(all_values)
|
||||
y_max = max(all_values)
|
||||
y_range = y_max - y_min
|
||||
buffer = max(y_range * 0.1, 1.0) # At least 1 degree buffer
|
||||
self._plot_widget.setYRange(y_min - buffer, y_max + buffer)
|
||||
else:
|
||||
self._plot_widget.setYRange(-50, 150)
|
||||
|
||||
# X-axis: measurement intervals (0-based index)
|
||||
num_points = len(series_data[0]) if series_data else 0
|
||||
x_axis = list(range(num_points))
|
||||
|
||||
# Define a set of distinct colors for series
|
||||
colors = [
|
||||
(255, 87, 87), # Red
|
||||
(66, 165, 245), # Blue
|
||||
(102, 187, 106), # Green
|
||||
(255, 167, 38), # Orange
|
||||
(171, 71, 188), # Purple
|
||||
(0, 188, 212), # Cyan
|
||||
(255, 112, 67), # Deep Orange
|
||||
(121, 85, 72), # Brown
|
||||
(92, 107, 192), # Indigo
|
||||
(48, 125, 117), # Teal
|
||||
]
|
||||
|
||||
# Add each sensor as a line series
|
||||
for i, sensor_name in enumerate(sensor_names):
|
||||
if i >= len(series_data):
|
||||
break
|
||||
|
||||
sensor_values = series_data[i]
|
||||
y_values = []
|
||||
for v in sensor_values:
|
||||
try:
|
||||
y_values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
y_values.append(0.0)
|
||||
|
||||
color = colors[i % len(colors)]
|
||||
pen = pg.mkPen(color=color, width=1)
|
||||
|
||||
curve = self._plot_widget.plot(x_axis, y_values, name=sensor_name, pen=pen)
|
||||
self._series.append(curve)
|
||||
|
||||
self._sensor_names = sensor_names
|
||||
|
||||
@Slot()
|
||||
def resetChart(self) -> None:
|
||||
"""Clear the chart."""
|
||||
if self._plot_widget:
|
||||
self._plot_widget.clear()
|
||||
self._series = []
|
||||
self._sensor_names = []
|
||||
|
||||
@Slot()
|
||||
def destroyPlotWidget(self) -> None:
|
||||
"""Destroy the plot widget."""
|
||||
if self._plot_window:
|
||||
self._plot_window.deleteLater()
|
||||
self._plot_window = None
|
||||
self._plot_widget = None
|
||||
self._series = []
|
||||
@@ -0,0 +1,139 @@
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
|
||||
|
||||
|
||||
# ===== Contour Generation =====
|
||||
class MarchingSquares:
|
||||
|
||||
# ===== Public API =====
|
||||
@staticmethod
|
||||
def generate_contours(grid: np.ndarray, levels: List[float]) -> List[ContourLine]:
|
||||
"""
|
||||
Generate contour lines for a 2D grid at specified levels.
|
||||
|
||||
Args:
|
||||
grid: 2D numpy array (shape: [width, height])
|
||||
levels: List of contour levels to compute
|
||||
|
||||
Returns:
|
||||
List of ContourLine objects
|
||||
"""
|
||||
if grid.size == 0:
|
||||
return []
|
||||
|
||||
width, height = grid.shape[0], grid.shape[1]
|
||||
contours = []
|
||||
|
||||
for level in levels:
|
||||
contour = ContourLine(level=level)
|
||||
|
||||
# Iterate over each cell (x, y) in the grid
|
||||
for y in range(height - 1):
|
||||
for x in range(width - 1):
|
||||
v0 = float(grid[x, y]) # top-left
|
||||
v1 = float(grid[x + 1, y]) # top-right
|
||||
v2 = float(grid[x + 1, y + 1]) # bottom-right
|
||||
v3 = float(grid[x, y + 1]) # bottom-left
|
||||
|
||||
if any(np.isnan([v0, v1, v2, v3])):
|
||||
continue # Skip cells with NaN values
|
||||
|
||||
state = (
|
||||
(1 if v0 > level else 0)
|
||||
| (2 if v1 > level else 0)
|
||||
| (4 if v2 > level else 0)
|
||||
| (8 if v3 > level else 0)
|
||||
)
|
||||
|
||||
seg = MarchingSquares._get_segment(
|
||||
x, y, v0, v1, v2, v3, level, state
|
||||
)
|
||||
if seg is not None:
|
||||
contour.segments.append(seg)
|
||||
|
||||
contours.append(contour)
|
||||
|
||||
return contours
|
||||
|
||||
# ===== Geometry Helpers =====
|
||||
@staticmethod
|
||||
def _lerp(
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
val1: float,
|
||||
val2: float,
|
||||
level: float,
|
||||
) -> Tuple[float, float]:
|
||||
"""Linear interpolation between (x1,y1) and (x2,y2)."""
|
||||
if val2 == val1:
|
||||
return (x1 + x2) / 2, (y1 + y2) / 2
|
||||
t = (level - val1) / (val2 - val1)
|
||||
px = x1 + t * (x2 - x1)
|
||||
py = y1 + t * (y2 - y1)
|
||||
return px, py
|
||||
|
||||
# ===== Segment Lookup =====
|
||||
@staticmethod
|
||||
def _get_segment(
|
||||
x: int,
|
||||
y: int,
|
||||
v0: float,
|
||||
v1: float,
|
||||
v2: float,
|
||||
v3: float,
|
||||
level: float,
|
||||
state: int,
|
||||
) -> Optional[ContourSegment]:
|
||||
"""Return a ContourSegment for the given cell and state."""
|
||||
if state in (10,): # Ambiguous case — skip
|
||||
return None
|
||||
|
||||
# Map C# states to Python logic
|
||||
if state == 1 or state == 14:
|
||||
start = MarchingSquares._lerp(x, y, x + 1, y, v0, v1, level)
|
||||
end = MarchingSquares._lerp(x, y, x, y + 1, v0, v3, level)
|
||||
elif state == 2 or state == 13:
|
||||
start = MarchingSquares._lerp(x + 1, y, x + 1, y + 1, v1, v2, level)
|
||||
end = MarchingSquares._lerp(x, y, x + 1, y, v0, v1, level)
|
||||
elif state == 3 or state == 12:
|
||||
start = MarchingSquares._lerp(x, y, x, y + 1, v0, v3, level)
|
||||
end = MarchingSquares._lerp(x + 1, y, x + 1, y + 1, v1, v2, level)
|
||||
elif state == 4 or state == 11:
|
||||
start = MarchingSquares._lerp(x + 1, y, x + 1, y + 1, v1, v2, level)
|
||||
end = MarchingSquares._lerp(x + 1, y + 1, x, y + 1, v2, v3, level)
|
||||
elif state == 5:
|
||||
start = MarchingSquares._lerp(x, y, x, y + 1, v0, v3, level)
|
||||
end = MarchingSquares._lerp(x + 1, y, x + 1, y + 1, v1, v2, level)
|
||||
elif state == 6 or state == 9:
|
||||
start = MarchingSquares._lerp(x, y, x + 1, y, v0, v1, level)
|
||||
end = MarchingSquares._lerp(x + 1, y + 1, x, y + 1, v2, v3, level)
|
||||
elif state == 7 or state == 8:
|
||||
start = MarchingSquares._lerp(x, y, x, y + 1, v0, v3, level)
|
||||
end = MarchingSquares._lerp(x + 1, y + 1, x, y + 1, v2, v3, level)
|
||||
else:
|
||||
return None
|
||||
|
||||
return ContourSegment(
|
||||
start_x=start[0], start_y=start[1], end_x=end[0], end_y=end[1]
|
||||
)
|
||||
|
||||
# ===== Color Mapping =====
|
||||
@staticmethod
|
||||
def color_from_level(
|
||||
value: float, min_val: float, max_val: float
|
||||
) -> Tuple[int, int, int]:
|
||||
"""Return (R, G, B) tuple for a value between min and max."""
|
||||
range_val = max_val - min_val
|
||||
if range_val == 0:
|
||||
t = 0.5
|
||||
else:
|
||||
t = max(0.0, min(1.0, (value - min_val) / range_val))
|
||||
|
||||
r = int(255 * t)
|
||||
b = int(255 * (1 - t))
|
||||
return (r, 0, b)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""RBF (thin-plate spline) heatmap field.
|
||||
|
||||
Uses CuPy for GPU acceleration when available, falls back to NumPy + SciPy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
|
||||
try:
|
||||
import cupy as _cupy # type: ignore
|
||||
BACKEND = "cupy"
|
||||
except Exception:
|
||||
_cupy = None
|
||||
BACKEND = "numpy"
|
||||
|
||||
_KERNEL = "thin_plate_spline"
|
||||
_SMOOTHING = 0.0
|
||||
|
||||
|
||||
def interpolate_field(
|
||||
xs: np.ndarray,
|
||||
ys: np.ndarray,
|
||||
vs: np.ndarray,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
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.
|
||||
|
||||
Args:
|
||||
xs, ys: sensor positions in mm (1-D arrays, length N)
|
||||
vs: sensor values (length N)
|
||||
width/height: output grid dimensions in pixels
|
||||
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)
|
||||
|
||||
xmin, xmax, ymin, ymax = extent
|
||||
gx = np.linspace(xmin, xmax, width)
|
||||
gy = np.linspace(ymin, ymax, height)
|
||||
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)
|
||||
|
||||
if round_clip:
|
||||
cx = (xmin + xmax) / 2
|
||||
cy = (ymin + ymax) / 2
|
||||
rx = (xmax - xmin) / 2
|
||||
ry = (ymax - ymin) / 2
|
||||
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
|
||||
field = np.where(dist <= 1.0, field, np.nan)
|
||||
|
||||
return field.astype(np.float64)
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""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 math
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Property, QPoint, Signal, Slot, Qt
|
||||
from PySide6.QtGui import (
|
||||
QBrush, QColor, QFont, QImage, QPainter, QPen, QPolygon,
|
||||
)
|
||||
from PySide6.QtQml import QmlElement
|
||||
from PySide6.QtQuick import QQuickPaintedItem
|
||||
|
||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
|
||||
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()
|
||||
colorsChanged = 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
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
def sensors(self, val: list) -> None:
|
||||
self._sensors = [Sensor(label=d["label"], x=float(d["x"]), y=float(d["y"]))
|
||||
for d in (val or [])]
|
||||
self._rebuild()
|
||||
self.sensorsChanged.emit()
|
||||
|
||||
@Property("QVariantList", notify=valuesChanged)
|
||||
def values(self) -> list:
|
||||
return self._values
|
||||
|
||||
@values.setter
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
def showLabels(self, val: bool) -> None:
|
||||
self._show_labels = bool(val)
|
||||
self.showLabelsChanged.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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
# ── internal ─────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
|
||||
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 - 4 px."""
|
||||
return (ds / 2 - 4) / 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:
|
||||
field = interpolate_field(
|
||||
xs, ys, vs,
|
||||
width=ds, height=ds,
|
||||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||
round_clip=True,
|
||||
)
|
||||
except Exception:
|
||||
self._heatmap = None
|
||||
return
|
||||
self._heatmap = self._field_to_qimage(field, ds)
|
||||
|
||||
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage:
|
||||
"""Apply a band-aware tri-color gradient → RGBA QImage."""
|
||||
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,
|
||||
)
|
||||
# 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)
|
||||
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8)
|
||||
|
||||
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
||||
|
||||
# ── paint ─────────────────────────────────────────────────────────────
|
||||
|
||||
def paint(self, painter: QPainter) -> None:
|
||||
ds = self._draw_size()
|
||||
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)
|
||||
|
||||
self._paint_markers(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)
|
||||
|
||||
# Concentric rings at actual sensor group radii (falls back to 25/50/75/100% when no sensors).
|
||||
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
|
||||
|
||||
id_font = QFont()
|
||||
id_font.setPointSize(max(5, r))
|
||||
id_font.setBold(True)
|
||||
|
||||
temp_font = QFont()
|
||||
temp_font.setPointSize(max(4, r - 1))
|
||||
|
||||
# Pre-compute ID font metrics for vertical centering
|
||||
painter.setFont(id_font)
|
||||
id_fm = painter.fontMetrics()
|
||||
id_line_h = id_fm.height()
|
||||
id_ascent = id_fm.ascent()
|
||||
|
||||
band_color = {
|
||||
"in_range": self._in_range_color,
|
||||
"high": self._high_color,
|
||||
"low": self._low_color,
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
# 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 - r, py - r, 2 * r, 2 * r)
|
||||
|
||||
if self._show_labels:
|
||||
has_temp = i < len(self._values)
|
||||
lx = px + r + 3
|
||||
# Two-line block: split the gap at dot center; single-line: original position
|
||||
y1 = (py - id_line_h // 2) if has_temp else (py + id_ascent // 2)
|
||||
|
||||
# Sensor ID — bold, muted text color
|
||||
painter.setFont(id_font)
|
||||
painter.setPen(QPen(self._text_color))
|
||||
painter.drawText(lx, y1, s.label)
|
||||
|
||||
# Temperature — band color, smaller font, below ID
|
||||
if has_temp:
|
||||
painter.setFont(temp_font)
|
||||
painter.setPen(QPen(color))
|
||||
painter.drawText(lx, y1 + id_line_h, f"{self._values[i]:.2f}")
|
||||
Reference in New Issue
Block a user