02327a7a21
- AlignmentThresholds dataclass + VisionEngine.__init__ for injection - alignment_state computed per snapshot (ready/aligning/fault) - MonitorPlot circle/vector/legend driven by state color - Status dot + label moved into POSITION VISUALIZATION panel header - CsvSessionSource + DemoSource pluggable DataSource layer - Synthetic DATA/ready/ and DATA/aligning/ sessions for visual testing - Fix CAM 3 label placement (below circle, not above)
252 lines
8.7 KiB
Python
252 lines
8.7 KiB
Python
"""Reusable Qt widgets"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any
|
|
|
|
from .qt import require_qt
|
|
|
|
|
|
QtCore, QtGui, QtWidgets = require_qt()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TODO P3.1: Add _STATE_COLORS and _DEFAULT_COLOR module-level constants here
|
|
#
|
|
# THINKING:
|
|
# Module-level avoids reconstructing the dict on every paintEvent repaint.
|
|
# Single source of truth for colors used in two places: drawing calls (P3.2)
|
|
# and the legend swatch (P3.3). _DEFAULT_COLOR retains the original orange
|
|
# so any snapshot predating P1.2 renders with pre-existing orange rather than
|
|
# silently going red. Cross-reference: used by P3.2 and P3.3.
|
|
#
|
|
# _STATE_COLORS: dict[str, str] = {
|
|
# "ready": "#22c55e",
|
|
# "aligning": "#f59e0b",
|
|
# "fault": "#ef4444",
|
|
# }
|
|
# _DEFAULT_COLOR = "#f97316" # defensive fallback; unexpected alignment_state only
|
|
# ---------------------------------------------------------------------------
|
|
_STATE_COLORS: dict[str, str] = {
|
|
"ready": "#22c55e",
|
|
"aligning": "#f59e0b",
|
|
"fault": "#ef4444",
|
|
}
|
|
_DEFAULT_COLOR = "#f97316"
|
|
|
|
|
|
class MonitorPlot(QtWidgets.QWidget):
|
|
"""Operator monitor plot driven by a UI snapshot."""
|
|
|
|
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self.setMinimumSize(430, 390)
|
|
self._snapshot: Any | None = None
|
|
|
|
def set_snapshot(self, snapshot: Any | None) -> None:
|
|
self._snapshot = snapshot
|
|
self.update()
|
|
|
|
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
|
|
del event
|
|
painter = QtGui.QPainter(self)
|
|
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
|
rect = self.rect().adjusted(0, 0, -1, -1)
|
|
painter.fillRect(rect, QtGui.QColor("#ffffff"))
|
|
|
|
legend_height = 32
|
|
plot_rect = rect.adjusted(30, 28, -30, -(legend_height + 16))
|
|
center = QtCore.QPointF(plot_rect.center())
|
|
radius = min(plot_rect.width(), plot_rect.height()) * 0.34
|
|
|
|
painter.setPen(
|
|
QtGui.QPen(QtGui.QColor("#93c5fd"), 2, QtCore.Qt.PenStyle.DashLine)
|
|
)
|
|
painter.drawEllipse(center, radius, radius)
|
|
painter.setPen(QtGui.QPen(QtGui.QColor("#64748b"), 1))
|
|
for label, angle in [
|
|
("CAM 1", -90),
|
|
("CAM 2", 0),
|
|
("CAM 3", 90),
|
|
("CAM 4", 180),
|
|
]:
|
|
point = _polar(center, radius * 1.18, angle)
|
|
painter.drawText(
|
|
_camera_label_rect(point, angle),
|
|
QtCore.Qt.AlignmentFlag.AlignCenter,
|
|
label,
|
|
)
|
|
|
|
if self._snapshot:
|
|
reference_radius = max(float(self._snapshot.reference_radius_mm), 1.0)
|
|
detected_radius = float(self._snapshot.detected_radius_mm)
|
|
detected_center_mm = self._snapshot.detected_center_mm
|
|
scale = radius / reference_radius
|
|
detected_center = QtCore.QPointF(
|
|
center.x() + float(detected_center_mm[0]) * scale,
|
|
center.y() + float(detected_center_mm[1]) * scale,
|
|
)
|
|
color = QtGui.QColor(
|
|
_STATE_COLORS.get(self._snapshot.alignment_state, _DEFAULT_COLOR)
|
|
)
|
|
painter.setPen(QtGui.QPen(color, 2))
|
|
painter.drawEllipse(
|
|
detected_center, detected_radius * scale, detected_radius * scale
|
|
)
|
|
painter.setPen(QtGui.QPen(QtGui.QColor("#2563eb"), 7))
|
|
painter.drawPoint(center)
|
|
painter.setPen(QtGui.QPen(color, 7))
|
|
painter.drawLine(center, detected_center)
|
|
painter.drawPoint(detected_center)
|
|
else:
|
|
painter.setPen(QtGui.QColor("#64748b"))
|
|
painter.drawText(
|
|
plot_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "No wafer read"
|
|
)
|
|
|
|
_draw_plot_legend(
|
|
painter,
|
|
rect,
|
|
rect.bottom() - legend_height // 2,
|
|
_STATE_COLORS.get(
|
|
self._snapshot.alignment_state if self._snapshot else "",
|
|
_DEFAULT_COLOR,
|
|
),
|
|
)
|
|
painter.end()
|
|
|
|
|
|
class OffsetCard(QtWidgets.QFrame):
|
|
"""Metric card with a separate unit label."""
|
|
|
|
def __init__(
|
|
self,
|
|
label: str,
|
|
unit: str,
|
|
value: str = "--",
|
|
parent: QtWidgets.QWidget | None = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self.setObjectName("MetricCard")
|
|
layout = QtWidgets.QVBoxLayout(self)
|
|
layout.setContentsMargins(16, 10, 16, 10)
|
|
layout.setSpacing(4)
|
|
label_widget = QtWidgets.QLabel(label)
|
|
label_widget.setObjectName("Metric")
|
|
row = QtWidgets.QHBoxLayout()
|
|
row.setSpacing(8)
|
|
self.value_widget = QtWidgets.QLabel(value)
|
|
self.value_widget.setObjectName("Value")
|
|
unit_widget = QtWidgets.QLabel(unit)
|
|
unit_widget.setObjectName("Unit")
|
|
row.addWidget(self.value_widget)
|
|
row.addWidget(unit_widget)
|
|
row.addStretch(1)
|
|
layout.addWidget(label_widget)
|
|
layout.addLayout(row)
|
|
|
|
def set_value(self, value: str) -> None:
|
|
self.value_widget.setText(value)
|
|
|
|
|
|
class CameraStatusCard(QtWidgets.QFrame):
|
|
"""Camera health row with a colored status badge."""
|
|
|
|
def __init__(self, label: str, parent: QtWidgets.QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self.setObjectName("MetricCard")
|
|
layout = QtWidgets.QHBoxLayout(self)
|
|
layout.setContentsMargins(20, 16, 20, 16)
|
|
layout.setSpacing(12)
|
|
self.name_label = QtWidgets.QLabel(label)
|
|
self.name_label.setObjectName("Metric")
|
|
self.badge = QtWidgets.QLabel("Wait")
|
|
self.badge.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
self.badge.setMinimumWidth(66)
|
|
layout.addWidget(self.name_label)
|
|
layout.addStretch(1)
|
|
layout.addWidget(self.badge)
|
|
self.set_status("wait")
|
|
|
|
def set_label(self, label: str) -> None:
|
|
self.name_label.setText(label)
|
|
|
|
def set_status(self, status: str, reading_text: str = "") -> None:
|
|
normalized = status.lower()
|
|
text = "OK"
|
|
color = "#e8fff6"
|
|
fg = "#006a55"
|
|
if normalized in {"warn", "noisy"}:
|
|
text = "Noisy"
|
|
color = "#fff3dc"
|
|
fg = "#835008"
|
|
elif normalized == "fail":
|
|
text = "Fail"
|
|
color = "#ffe8e8"
|
|
fg = "#a53535"
|
|
elif normalized == "wait":
|
|
text = "Wait"
|
|
color = "#353632"
|
|
fg = "#b8b8b2"
|
|
self.badge.setText(reading_text or text)
|
|
self.badge.setStyleSheet(
|
|
f"background: {color}; color: {fg}; border-radius: 15px; "
|
|
"padding: 6px 12px; font-weight: 750;"
|
|
)
|
|
|
|
|
|
def _polar(center: QtCore.QPointF, radius: float, angle_deg: float) -> QtCore.QPointF:
|
|
radians = math.radians(angle_deg)
|
|
return QtCore.QPointF(
|
|
center.x() + math.cos(radians) * radius,
|
|
center.y() + math.sin(radians) * radius,
|
|
)
|
|
|
|
|
|
def _draw_plot_legend(
|
|
painter: QtGui.QPainter,
|
|
rect: QtCore.QRect,
|
|
legend_y: int,
|
|
detected_color: str = _DEFAULT_COLOR,
|
|
) -> None:
|
|
"""Draw Reference / Detected legend centered with measured spacing."""
|
|
line_width = 36
|
|
text_gap = 8
|
|
entry_gap = 28
|
|
entries = [
|
|
(
|
|
QtGui.QPen(QtGui.QColor("#93c5fd"), 2, QtCore.Qt.PenStyle.DashLine),
|
|
"Reference",
|
|
),
|
|
(QtGui.QPen(QtGui.QColor(detected_color), 2), "Detected"),
|
|
]
|
|
|
|
metrics = QtGui.QFontMetrics(painter.font())
|
|
text_y = legend_y + 5
|
|
entry_widths = [
|
|
line_width + text_gap + metrics.horizontalAdvance(label) for _, label in entries
|
|
]
|
|
total_width = sum(entry_widths) + entry_gap * (len(entries) - 1)
|
|
x = rect.center().x() - total_width / 2
|
|
|
|
painter.setPen(QtGui.QColor("#64748b"))
|
|
for index, (line_pen, label) in enumerate(entries):
|
|
painter.setPen(line_pen)
|
|
painter.drawLine(int(x), legend_y, int(x + line_width), legend_y)
|
|
painter.setPen(QtGui.QColor("#64748b"))
|
|
painter.drawText(int(x + line_width + text_gap), text_y, label)
|
|
x += entry_widths[index] + entry_gap
|
|
|
|
|
|
def _camera_label_rect(point: QtCore.QPointF, angle_deg: float) -> QtCore.QRectF:
|
|
width, height = 56.0, 18.0
|
|
if angle_deg == -90:
|
|
return QtCore.QRectF(
|
|
point.x() - width / 2, point.y() - height - 6, width, height
|
|
)
|
|
if angle_deg == 90:
|
|
return QtCore.QRectF(point.x() - width / 2, point.y() + 6, width, height)
|
|
if angle_deg == 0:
|
|
return QtCore.QRectF(point.x() + 8, point.y() - height / 2, width, height)
|
|
return QtCore.QRectF(point.x() - width - 8, point.y() - height / 2, width, height)
|