Add initial project structure and configuration files
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Local runtime / OS
|
||||||
|
baseline.json
|
||||||
|
.DS_Store
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/*.local.json
|
||||||
|
|
||||||
|
# Not part of the public demo
|
||||||
|
.agents/
|
||||||
|
.kilocode/
|
||||||
|
.palace/
|
||||||
|
.pi/
|
||||||
|
mcp-server/
|
||||||
|
nanoberry_doc/
|
||||||
|
docs/demo-integration-plan.md
|
||||||
|
cal.ipynb
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
.mcp.json
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[General]
|
||||||
|
no-cmake-calls=true
|
||||||
|
importPaths=.venv/lib/python3.11/site-packages/PySide6/Qt/qml
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"python.testing.pytestArgs": ["tests"],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"qt-core.qtInstallationRoot": "${workspaceFolder}/.venv/lib/python3.11/site-packages/PySide6/Qt",
|
||||||
|
"qt-qml.qmlls.enabled": true,
|
||||||
|
"qt-qml.qmlls.customExePath": "${workspaceFolder}/.venv/lib/python3.11/site-packages/PySide6/qmlls",
|
||||||
|
"qt-qml.qmlls.additionalImportPaths": [
|
||||||
|
"${workspaceFolder}/.venv/lib/python3.11/site-packages/PySide6/Qt/qml"
|
||||||
|
],
|
||||||
|
"qt-qml.qmlls.useNoCMakeCalls": true
|
||||||
|
}
|
||||||
+12824
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
# Wafer Position Monitor (demo)
|
||||||
|
|
||||||
|
PySide6 desktop demo: load NanoBerry edge CSVs from `MASTER_SD/`, fit a wafer circle, show **X / Y / theta** offset and **sigma** vs a calibration baseline. No live camera required.
|
||||||
|
|
||||||
|
**Stack:** Python 3.11+, PySide6, NumPy.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
wafer-user # or: python src/wafer_edge/App.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Click **Read Wafer** to load the demo CSVs. Tests: `python -m pytest tests/` (UI smoke needs a display).
|
||||||
|
|
||||||
|
## Demo data
|
||||||
|
|
||||||
|
CSV files in `[MASTER_SD/](MASTER_SD/)`. Names: `{S|R}{1|2}{seq}P0.CSV` — local/remote board, SPI camera, 4-digit sequence (e.g. `S10540P0.CSV`).
|
||||||
|
|
||||||
|
Default cameras: `CsvSource.DEMO_CAMERA_PATHS`. Change that dict or use `parse_csv_filename()` for other captures.
|
||||||
|
|
||||||
|
## Calibration
|
||||||
|
|
||||||
|
First run creates `baseline.json` (gitignored). Key fields: `center_mm`, `radius_mm` (75), `center_px`, `radius_px` (pixel scale).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
| ----------------------- | --------------------------------- |
|
||||||
|
| `src/wafer_edge/App.py` | Main window |
|
||||||
|
| `VisionEngine.py` | Circle fit + offsets (no Qt) |
|
||||||
|
| `CsvSource.py` | CSV loader |
|
||||||
|
| `ui/widgets.py` | Plot + metric cards |
|
||||||
|
| `ui/Theme.qml` | Styles (loaded via `ui/theme.py`) |
|
||||||
|
|
||||||
|
**Pipeline:** `DEMO_CAMERA_PATHS` → `load_camera_records_from_csv()` → `snapshot_from_records()` → UI.
|
||||||
|
|
||||||
|

|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"center_mm": [0.0, 0.0],
|
||||||
|
"radius_mm": 75.0,
|
||||||
|
"theta_arm_mm": 126.0,
|
||||||
|
"center_px": [176.0, 166.9],
|
||||||
|
"radius_px": 120.9
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "wafer-edge-detection"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "PySide6 demo for a wafer edge position monitor (CSV replay, circle fit, offset UI)."
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"PySide6>=6.7",
|
||||||
|
"numpy>=1.26",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
wafer-user = "wafer_edge.App:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"wafer_edge.ui" = ["Theme.qml"]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 724 KiB |
@@ -0,0 +1,187 @@
|
|||||||
|
"""PySide6 UI for cam wafer demo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if __package__ in {None, ""}:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from wafer_edge.Calibration import load_or_create_baseline
|
||||||
|
from wafer_edge.CsvSource import (
|
||||||
|
DEMO_CAMERA_PATHS,
|
||||||
|
load_camera_records_from_csv,
|
||||||
|
) # change files at CSVSources.py
|
||||||
|
from wafer_edge.VisionEngine import MonitorSnapshot, VisionEngine
|
||||||
|
from wafer_edge.ui.qt import require_qt
|
||||||
|
from wafer_edge.ui.theme import load_qss, theme_color
|
||||||
|
from wafer_edge.ui.widgets import CameraStatusCard, MonitorPlot, OffsetCard
|
||||||
|
else:
|
||||||
|
from .Calibration import load_or_create_baseline
|
||||||
|
from .CsvSource import DEMO_CAMERA_PATHS, load_camera_records_from_csv
|
||||||
|
from .VisionEngine import MonitorSnapshot, VisionEngine
|
||||||
|
from .ui.qt import require_qt
|
||||||
|
from .ui.theme import load_qss, theme_color
|
||||||
|
from .ui.widgets import CameraStatusCard, MonitorPlot, OffsetCard
|
||||||
|
|
||||||
|
QtCore, QtGui, QtWidgets = require_qt()
|
||||||
|
|
||||||
|
|
||||||
|
class UserWindow(QtWidgets.QMainWindow):
|
||||||
|
"""Client/operator wafer monitor window."""
|
||||||
|
|
||||||
|
def __init__(self, engine: VisionEngine | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.engine = engine or VisionEngine()
|
||||||
|
self.baseline = load_or_create_baseline()
|
||||||
|
self.camera_cards: dict[str, CameraStatusCard] = {}
|
||||||
|
|
||||||
|
self.setWindowTitle("Wafer Position Monitor")
|
||||||
|
self.setMinimumSize(1400, 820)
|
||||||
|
self.resize(1120, 760)
|
||||||
|
|
||||||
|
root = QtWidgets.QWidget()
|
||||||
|
root.setObjectName("ClientRoot")
|
||||||
|
self.setCentralWidget(root)
|
||||||
|
|
||||||
|
outer = QtWidgets.QVBoxLayout(root)
|
||||||
|
outer.setContentsMargins(28, 28, 28, 28)
|
||||||
|
outer.setSpacing(24)
|
||||||
|
|
||||||
|
self._build_header(outer)
|
||||||
|
self._build_body(outer)
|
||||||
|
self.read_wafer()
|
||||||
|
|
||||||
|
def _build_header(self, outer: QtWidgets.QVBoxLayout) -> None:
|
||||||
|
header = QtWidgets.QFrame()
|
||||||
|
header.setObjectName("HeroHeader")
|
||||||
|
header_layout = QtWidgets.QHBoxLayout(header)
|
||||||
|
header_layout.setContentsMargins(28, 22, 28, 22)
|
||||||
|
header_layout.setSpacing(18)
|
||||||
|
|
||||||
|
title_column = QtWidgets.QVBoxLayout()
|
||||||
|
title_column.setSpacing(4)
|
||||||
|
title = QtWidgets.QLabel("Wafer Position Monitor")
|
||||||
|
title.setObjectName("HeroTitle")
|
||||||
|
self.source_label = QtWidgets.QLabel("User App")
|
||||||
|
self.source_label.setObjectName("HeroSubtitle")
|
||||||
|
title_column.addWidget(title)
|
||||||
|
title_column.addWidget(self.source_label)
|
||||||
|
|
||||||
|
self.status_dot = QtWidgets.QLabel()
|
||||||
|
self.status_dot.setFixedSize(14, 14)
|
||||||
|
self.system_status = QtWidgets.QLabel("Ready")
|
||||||
|
self.system_status.setObjectName("SystemStatus")
|
||||||
|
|
||||||
|
header_layout.addLayout(title_column)
|
||||||
|
header_layout.addStretch(1)
|
||||||
|
header_layout.addWidget(self.status_dot)
|
||||||
|
header_layout.addWidget(self.system_status)
|
||||||
|
outer.addWidget(header)
|
||||||
|
|
||||||
|
def _build_body(self, outer: QtWidgets.QVBoxLayout) -> None:
|
||||||
|
body = QtWidgets.QWidget()
|
||||||
|
body_layout = QtWidgets.QGridLayout(body)
|
||||||
|
body_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
body_layout.setHorizontalSpacing(24)
|
||||||
|
body_layout.setVerticalSpacing(24)
|
||||||
|
|
||||||
|
visualization = self._panel("POSITION VISUALIZATION")
|
||||||
|
self.plot = MonitorPlot()
|
||||||
|
visualization.layout().addWidget(self.plot, 1)
|
||||||
|
body_layout.addWidget(visualization, 0, 0, 2, 1)
|
||||||
|
|
||||||
|
results = self._panel("OFFSET RESULTS")
|
||||||
|
metric_grid = QtWidgets.QGridLayout()
|
||||||
|
metric_grid.setHorizontalSpacing(14)
|
||||||
|
metric_grid.setVerticalSpacing(14)
|
||||||
|
self.x_card = OffsetCard("X offset", "mm")
|
||||||
|
self.y_card = OffsetCard("Y offset", "mm")
|
||||||
|
self.theta_card = OffsetCard("theta offset", "deg")
|
||||||
|
self.sigma_card = OffsetCard("sigma dispersion", "mm")
|
||||||
|
metric_grid.addWidget(self.x_card, 0, 0)
|
||||||
|
metric_grid.addWidget(self.y_card, 0, 1)
|
||||||
|
metric_grid.addWidget(self.theta_card, 1, 0)
|
||||||
|
metric_grid.addWidget(self.sigma_card, 1, 1)
|
||||||
|
results.layout().addLayout(metric_grid)
|
||||||
|
body_layout.addWidget(results, 0, 1)
|
||||||
|
|
||||||
|
cameras = self._panel("CAMERA READINGS")
|
||||||
|
self.camera_grid = QtWidgets.QGridLayout()
|
||||||
|
self.camera_grid.setHorizontalSpacing(14)
|
||||||
|
self.camera_grid.setVerticalSpacing(14)
|
||||||
|
cameras.layout().addLayout(self.camera_grid)
|
||||||
|
body_layout.addWidget(cameras, 1, 1)
|
||||||
|
|
||||||
|
self.read_button = QtWidgets.QPushButton("Read Wafer")
|
||||||
|
self.read_button.setObjectName("PrimaryButton")
|
||||||
|
self.read_button.clicked.connect(self.read_wafer)
|
||||||
|
body_layout.addWidget(self.read_button, 2, 0, 1, 2)
|
||||||
|
|
||||||
|
body_layout.setColumnStretch(0, 11)
|
||||||
|
body_layout.setColumnStretch(1, 10)
|
||||||
|
body_layout.setRowStretch(0, 1)
|
||||||
|
body_layout.setRowStretch(1, 1)
|
||||||
|
outer.addWidget(body, 1)
|
||||||
|
|
||||||
|
def _panel(self, title: str) -> QtWidgets.QFrame:
|
||||||
|
panel = QtWidgets.QFrame()
|
||||||
|
panel.setObjectName("ClientPanel")
|
||||||
|
layout = QtWidgets.QVBoxLayout(panel)
|
||||||
|
layout.setContentsMargins(28, 26, 28, 26)
|
||||||
|
layout.setSpacing(24)
|
||||||
|
|
||||||
|
label = QtWidgets.QLabel(title)
|
||||||
|
label.setObjectName("PanelTitle")
|
||||||
|
layout.addWidget(label)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def read_wafer(self) -> None:
|
||||||
|
try:
|
||||||
|
records = load_camera_records_from_csv(DEMO_CAMERA_PATHS, self.baseline)
|
||||||
|
snapshot = self.engine.snapshot_from_records(records, self.baseline)
|
||||||
|
except FileNotFoundError:
|
||||||
|
snapshot = self.engine.demo_snapshot(self.baseline)
|
||||||
|
self._apply_snapshot(snapshot)
|
||||||
|
|
||||||
|
def _apply_snapshot(self, snapshot: MonitorSnapshot) -> None:
|
||||||
|
self.source_label.setText(snapshot.source_label)
|
||||||
|
self.system_status.setText(snapshot.system_status)
|
||||||
|
self._set_status_dot(snapshot.system_status)
|
||||||
|
|
||||||
|
self.plot.set_snapshot(snapshot)
|
||||||
|
self.x_card.set_value(f"{snapshot.x_offset_mm:+.1f}")
|
||||||
|
self.y_card.set_value(f"{snapshot.y_offset_mm:+.1f}")
|
||||||
|
self.theta_card.set_value(f"{snapshot.theta_offset_deg:+.1f}")
|
||||||
|
self.sigma_card.set_value(f"{snapshot.sigma_mm:.2f}")
|
||||||
|
self._sync_camera_cards(snapshot)
|
||||||
|
|
||||||
|
def _sync_camera_cards(self, snapshot: MonitorSnapshot) -> None:
|
||||||
|
for index, reading in enumerate(snapshot.camera_readings):
|
||||||
|
card = self.camera_cards.get(reading.camera_id)
|
||||||
|
if card is None:
|
||||||
|
card = CameraStatusCard(reading.label)
|
||||||
|
self.camera_cards[reading.camera_id] = card
|
||||||
|
self.camera_grid.addWidget(card, index // 2, index % 2)
|
||||||
|
card.set_label(reading.label)
|
||||||
|
card.set_status(reading.status, reading.reading_text)
|
||||||
|
|
||||||
|
def _set_status_dot(self, status: str) -> None:
|
||||||
|
normalized = status.lower()
|
||||||
|
color = theme_color("fg_positive")
|
||||||
|
if normalized in {"warning", "review", "degraded"}:
|
||||||
|
color = theme_color("fg_warning")
|
||||||
|
elif normalized in {"offline", "error", "fault"}:
|
||||||
|
color = theme_color("fg_error")
|
||||||
|
self.status_dot.setStyleSheet(f"background: {color}; border-radius: 7px;")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
||||||
|
app.setStyleSheet(load_qss())
|
||||||
|
window = UserWindow()
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Baseline calibration storage for the beta demo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BASELINE_PATH = Path("baseline.json")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BaselineCalibration:
|
||||||
|
"""Perfect jig baseline used as the reference circle"""
|
||||||
|
|
||||||
|
center_mm: tuple[float, float] = (0.0, 0.0)
|
||||||
|
radius_mm: float = 75.0
|
||||||
|
theta_arm_mm: float = 126.0
|
||||||
|
center_px: tuple[float, float] = (176.0, 166.9)
|
||||||
|
radius_px: float = 120.9
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "BaselineCalibration":
|
||||||
|
return cls(
|
||||||
|
center_mm=tuple(data.get("center_mm", (0.0, 0.0))),
|
||||||
|
radius_mm=float(data.get("radius_mm", 75.0)),
|
||||||
|
theta_arm_mm=float(data.get("theta_arm_mm", 126.0)),
|
||||||
|
center_px=tuple(data.get("center_px", (176.0, 166.9))),
|
||||||
|
radius_px=float(data.get("radius_px", 120.9)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def default_baseline() -> BaselineCalibration:
|
||||||
|
return BaselineCalibration()
|
||||||
|
|
||||||
|
|
||||||
|
def load_baseline(path: Path = DEFAULT_BASELINE_PATH) -> BaselineCalibration:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return BaselineCalibration.from_dict(json.load(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def save_baseline(
|
||||||
|
baseline: BaselineCalibration,
|
||||||
|
path: Path = DEFAULT_BASELINE_PATH,
|
||||||
|
) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(baseline.to_dict(), handle, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_create_baseline(path: Path = DEFAULT_BASELINE_PATH) -> BaselineCalibration:
|
||||||
|
if path.exists():
|
||||||
|
return load_baseline(path)
|
||||||
|
baseline = default_baseline()
|
||||||
|
save_baseline(baseline, path)
|
||||||
|
return baseline
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""CSV edge-detection data loader"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if __package__ in {None, ""}:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from wafer_edge.Calibration import BaselineCalibration
|
||||||
|
from wafer_edge.VisionEngine import CameraEdgeRecord, filter_ring_outliers
|
||||||
|
else:
|
||||||
|
from .Calibration import BaselineCalibration
|
||||||
|
from .VisionEngine import CameraEdgeRecord, filter_ring_outliers
|
||||||
|
|
||||||
|
|
||||||
|
def demo_data_dir() -> Path:
|
||||||
|
"""Locate bundled MASTER_SD CSVs"""
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
candidates = [
|
||||||
|
Path.cwd() / "MASTER_SD",
|
||||||
|
here.parents[2] / "MASTER_SD",
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_dir():
|
||||||
|
return path
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def load_camera_records_from_csv(
|
||||||
|
paths: dict[str, Path],
|
||||||
|
baseline: BaselineCalibration,
|
||||||
|
) -> list[CameraEdgeRecord]:
|
||||||
|
"""Load per-camera edge CSVs, convert px->mm, filter ring outliers."""
|
||||||
|
cx, cy = baseline.center_px
|
||||||
|
scale = baseline.radius_mm / baseline.radius_px
|
||||||
|
records: list[CameraEdgeRecord] = []
|
||||||
|
|
||||||
|
for camera_id, path in paths.items():
|
||||||
|
path = Path(path)
|
||||||
|
status = "ok"
|
||||||
|
pts_px: list[tuple[float, float]] = []
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for raw in handle:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("#"):
|
||||||
|
if "remote_begin" in line.lower():
|
||||||
|
upper = line.upper()
|
||||||
|
if "STATUS=ERROR" in upper:
|
||||||
|
status = "error"
|
||||||
|
elif "STATUS=OK" in upper:
|
||||||
|
status = "ok"
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.replace(",", " ").split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pts_px.append((float(parts[0]), float(parts[1])))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pts_px:
|
||||||
|
arr_px = np.asarray(pts_px, dtype=float)
|
||||||
|
pts_mm = (arr_px - np.array([cx, cy])) * scale
|
||||||
|
pts_as_tuples = [(float(row[0]), float(row[1])) for row in pts_mm]
|
||||||
|
else:
|
||||||
|
pts_as_tuples = []
|
||||||
|
|
||||||
|
filtered_pts = filter_ring_outliers(pts_as_tuples, band_mm=5.0)
|
||||||
|
records.append(
|
||||||
|
CameraEdgeRecord(
|
||||||
|
camera_id=camera_id,
|
||||||
|
label=path.stem,
|
||||||
|
points=filtered_pts,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv_filename(path: Path) -> dict[str, object]:
|
||||||
|
"""Parse NanoBerry CSV filename: {S|R}{1|2}{seq}P0.CSV."""
|
||||||
|
match = re.fullmatch(r"([SR])([12])(\d{4})P0\.CSV", path.name, re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Unrecognized filename: {path.name!r}")
|
||||||
|
|
||||||
|
board_char, spi, seq = match.groups()
|
||||||
|
board = "LOCAL" if board_char.upper() == "S" else "REMOTE"
|
||||||
|
camera_id = f"{'s' if board == 'LOCAL' else 'r'}{spi}_{seq}"
|
||||||
|
return {
|
||||||
|
"board": board,
|
||||||
|
"spi": int(spi),
|
||||||
|
"seq": seq,
|
||||||
|
"camera_id": camera_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_DATA = demo_data_dir()
|
||||||
|
DEMO_CAMERA_PATHS: dict[str, Path] = {
|
||||||
|
"cam-1 (S1)": _DATA / "S10540P0.CSV",
|
||||||
|
"cam-2 (S2)": _DATA / "S20540P0.CSV",
|
||||||
|
"cam-3 (R1)": _DATA / "R10540P0.CSV",
|
||||||
|
"cam-4 (R2)": _DATA / "R20541P0.CSV",
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Vision and offset math for the beta wafer monitor demo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
if __package__ in {None, ""}:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from wafer_edge.Calibration import BaselineCalibration
|
||||||
|
else:
|
||||||
|
from .Calibration import BaselineCalibration
|
||||||
|
|
||||||
|
|
||||||
|
Point = tuple[float, float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraEdgeRecord:
|
||||||
|
camera_id: str
|
||||||
|
label: str
|
||||||
|
points: list[Point]
|
||||||
|
status: str = (
|
||||||
|
"ok" # "ok" | "noisy" | "error" — set by data source (CsvSource reads from CSV header)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraReading:
|
||||||
|
camera_id: str
|
||||||
|
label: str
|
||||||
|
status: str
|
||||||
|
reading_text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Circle:
|
||||||
|
center_mm: Point
|
||||||
|
radius_mm: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MonitorSnapshot:
|
||||||
|
x_offset_mm: float
|
||||||
|
y_offset_mm: float
|
||||||
|
theta_offset_deg: float
|
||||||
|
sigma_mm: float
|
||||||
|
reference_radius_mm: float
|
||||||
|
detected_radius_mm: float
|
||||||
|
detected_center_mm: Point
|
||||||
|
camera_readings: list[CameraReading]
|
||||||
|
system_status: str
|
||||||
|
source_label: str
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VisionEngine:
|
||||||
|
"""Computes edge fitting and offsets from camera edge records."""
|
||||||
|
|
||||||
|
def canny_edges(self, image: object) -> list[Point]:
|
||||||
|
"""Placeholder for future Canny extraction from a camera image."""
|
||||||
|
del image
|
||||||
|
raise NotImplementedError("Canny image processing is not wired for beta.")
|
||||||
|
|
||||||
|
def demo_camera_records(
|
||||||
|
self, baseline: BaselineCalibration
|
||||||
|
) -> list[CameraEdgeRecord]:
|
||||||
|
"""Return perfect-world demo edge points from four 90 degree camera arcs."""
|
||||||
|
detected_center = (2.3, -1.1)
|
||||||
|
radius = baseline.radius_mm
|
||||||
|
arcs = [
|
||||||
|
("cam-1", "Cam 1", -135.0, -45.0),
|
||||||
|
("cam-2", "Cam 2", -45.0, 45.0),
|
||||||
|
("cam-3", "Cam 3", 45.0, 135.0),
|
||||||
|
("cam-4", "Cam 4", 135.0, 225.0),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
CameraEdgeRecord(
|
||||||
|
camera_id, label, _arc_points(detected_center, radius, start, stop)
|
||||||
|
)
|
||||||
|
for camera_id, label, start, stop in arcs
|
||||||
|
]
|
||||||
|
|
||||||
|
def snapshot_from_records(
|
||||||
|
self,
|
||||||
|
camera_records: Iterable[CameraEdgeRecord],
|
||||||
|
baseline: BaselineCalibration,
|
||||||
|
source_label: str = "User App",
|
||||||
|
) -> MonitorSnapshot:
|
||||||
|
records = list(camera_records)
|
||||||
|
all_points = [point for record in records for point in record.points]
|
||||||
|
detected = fit_circle(all_points)
|
||||||
|
sigma = circle_sigma(all_points, detected)
|
||||||
|
|
||||||
|
x_offset = detected.center_mm[0] - baseline.center_mm[0]
|
||||||
|
y_offset = detected.center_mm[1] - baseline.center_mm[1]
|
||||||
|
theta_offset = -math.degrees(math.atan2(y_offset, baseline.theta_arm_mm))
|
||||||
|
|
||||||
|
readings = [
|
||||||
|
CameraReading(record.camera_id, record.label, record.status)
|
||||||
|
for record in records
|
||||||
|
]
|
||||||
|
|
||||||
|
# Derive system_status from worst camera state
|
||||||
|
statuses = {record.status for record in records}
|
||||||
|
if "error" in statuses:
|
||||||
|
system_status = "Fault"
|
||||||
|
elif "noisy" in statuses:
|
||||||
|
system_status = "Degraded"
|
||||||
|
else:
|
||||||
|
system_status = "Ready"
|
||||||
|
|
||||||
|
return MonitorSnapshot(
|
||||||
|
x_offset_mm=x_offset,
|
||||||
|
y_offset_mm=y_offset,
|
||||||
|
theta_offset_deg=theta_offset,
|
||||||
|
sigma_mm=sigma,
|
||||||
|
reference_radius_mm=baseline.radius_mm,
|
||||||
|
detected_radius_mm=detected.radius_mm,
|
||||||
|
detected_center_mm=detected.center_mm,
|
||||||
|
camera_readings=readings,
|
||||||
|
system_status=system_status,
|
||||||
|
source_label=source_label,
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def demo_snapshot(self, baseline: BaselineCalibration) -> MonitorSnapshot:
|
||||||
|
return self.snapshot_from_records(
|
||||||
|
self.demo_camera_records(baseline), baseline, source_label="User App - Simulated Data"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_ring_outliers(
|
||||||
|
points: Iterable[Point],
|
||||||
|
band_mm: float = 5.0,
|
||||||
|
iterations: int = 3,
|
||||||
|
) -> list[Point]:
|
||||||
|
|
||||||
|
pts = list(points)
|
||||||
|
if len(pts) < 3:
|
||||||
|
return pts
|
||||||
|
|
||||||
|
for _ in range(iterations):
|
||||||
|
try:
|
||||||
|
circle = fit_circle(pts)
|
||||||
|
except ValueError:
|
||||||
|
# Degenerate fit — can't fit a circle, return what we have
|
||||||
|
break
|
||||||
|
|
||||||
|
cx, cy = circle.center_mm
|
||||||
|
r = circle.radius_mm
|
||||||
|
pts = [(x, y) for x, y in pts if abs(math.hypot(x - cx, y - cy) - r) <= band_mm]
|
||||||
|
if len(pts) < 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def fit_circle(points: Iterable[Point]) -> Circle:
|
||||||
|
"""Fit a circle to XY edge points using a small algebraic least-squares solve."""
|
||||||
|
items = list(points)
|
||||||
|
if len(items) < 3:
|
||||||
|
raise ValueError("At least three edge points are required to fit a circle.")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
values = []
|
||||||
|
for x, y in items:
|
||||||
|
rows.append((x, y, 1.0))
|
||||||
|
values.append(-(x * x + y * y))
|
||||||
|
|
||||||
|
normal = [[0.0 for _ in range(3)] for _ in range(3)]
|
||||||
|
rhs = [0.0, 0.0, 0.0]
|
||||||
|
for row, value in zip(rows, values):
|
||||||
|
for i in range(3):
|
||||||
|
rhs[i] += row[i] * value
|
||||||
|
for j in range(3):
|
||||||
|
normal[i][j] += row[i] * row[j]
|
||||||
|
|
||||||
|
d_value, e_value, f_value = _solve_3x3(normal, rhs)
|
||||||
|
center = (-d_value / 2.0, -e_value / 2.0)
|
||||||
|
radius_sq = center[0] * center[0] + center[1] * center[1] - f_value
|
||||||
|
return Circle(center, math.sqrt(max(radius_sq, 0.0)))
|
||||||
|
|
||||||
|
|
||||||
|
def circle_sigma(points: Iterable[Point], circle: Circle) -> float:
|
||||||
|
residuals = [
|
||||||
|
math.hypot(x - circle.center_mm[0], y - circle.center_mm[1]) - circle.radius_mm
|
||||||
|
for x, y in points
|
||||||
|
]
|
||||||
|
if not residuals:
|
||||||
|
return 0.0
|
||||||
|
return math.sqrt(sum(value * value for value in residuals) / len(residuals))
|
||||||
|
|
||||||
|
|
||||||
|
def _arc_points(
|
||||||
|
center: Point, radius: float, start_deg: float, stop_deg: float
|
||||||
|
) -> list[Point]:
|
||||||
|
step_count = 24
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
center[0]
|
||||||
|
+ radius
|
||||||
|
* math.cos(
|
||||||
|
math.radians(start_deg + (stop_deg - start_deg) * i / step_count)
|
||||||
|
),
|
||||||
|
center[1]
|
||||||
|
+ radius
|
||||||
|
* math.sin(
|
||||||
|
math.radians(start_deg + (stop_deg - start_deg) * i / step_count)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for i in range(step_count + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_3x3(
|
||||||
|
matrix: list[list[float]], values: list[float]
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
|
rows = [matrix[i][:] + [values[i]] for i in range(3)]
|
||||||
|
for pivot in range(3):
|
||||||
|
best = max(range(pivot, 3), key=lambda row: abs(rows[row][pivot]))
|
||||||
|
rows[pivot], rows[best] = rows[best], rows[pivot]
|
||||||
|
if abs(rows[pivot][pivot]) < 1e-12:
|
||||||
|
raise ValueError("Circle fit failed because points are degenerate.")
|
||||||
|
scale = rows[pivot][pivot]
|
||||||
|
rows[pivot] = [value / scale for value in rows[pivot]]
|
||||||
|
for row_index in range(3):
|
||||||
|
if row_index == pivot:
|
||||||
|
continue
|
||||||
|
factor = rows[row_index][pivot]
|
||||||
|
rows[row_index] = [
|
||||||
|
current - factor * pivot_value
|
||||||
|
for current, pivot_value in zip(rows[row_index], rows[pivot])
|
||||||
|
]
|
||||||
|
return rows[0][3], rows[1][3], rows[2][3]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Wafer edge monitor beta demo package."""
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"App",
|
||||||
|
"Calibration",
|
||||||
|
"VisionEngine",
|
||||||
|
"ui",
|
||||||
|
]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
pragma Singleton
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monochrome white theme for the wafer monitor app.
|
||||||
|
* Qt Widgets app loads the `qss` property via wafer_edge.ui.theme.load_qss().
|
||||||
|
*/
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// ── Colors ──────────────────────────────────────────────
|
||||||
|
readonly property color bg_page: "#f6f8fb"
|
||||||
|
readonly property color bg_card: "#ffffff"
|
||||||
|
readonly property color bg_card_alt: "#f8fafc"
|
||||||
|
readonly property color fg_primary: "#0f172a"
|
||||||
|
readonly property color fg_secondary: "#334155"
|
||||||
|
readonly property color fg_muted: "#64748b"
|
||||||
|
readonly property color fg_accent: "#2563eb"
|
||||||
|
readonly property color fg_positive: "#21b98a"
|
||||||
|
readonly property color fg_warning: "#d8a657"
|
||||||
|
readonly property color fg_error: "#e35d5b"
|
||||||
|
readonly property color border_soft: "#dbe3ee"
|
||||||
|
readonly property color border_card: "#e2e8f0"
|
||||||
|
readonly property color border_btn: "#cbd5e1"
|
||||||
|
readonly property color border_btn_hv: "#94a3b8"
|
||||||
|
|
||||||
|
// ── Radii ───────────────────────────────────────────────
|
||||||
|
readonly property int radius_panel: 14
|
||||||
|
readonly property int radius_card: 10
|
||||||
|
readonly property int radius_btn: 8
|
||||||
|
readonly property int radius_badge: 15
|
||||||
|
|
||||||
|
// ── Spacing ─────────────────────────────────────────────
|
||||||
|
readonly property int margin_outer: 28
|
||||||
|
readonly property int spacing_outer: 24
|
||||||
|
readonly property int margin_panel: 26
|
||||||
|
readonly property int margin_card: 20
|
||||||
|
readonly property int padding_btn: 12
|
||||||
|
|
||||||
|
// ── Typography ──────────────────────────────────────────
|
||||||
|
readonly property string font_family: "Inter, SF Pro Text, Arial"
|
||||||
|
|
||||||
|
readonly property int fs_hero_title: 28
|
||||||
|
readonly property int fs_hero_sub: 20
|
||||||
|
readonly property int fs_panel_title: 20
|
||||||
|
readonly property int fs_system_status: 23
|
||||||
|
readonly property int fs_metric: 18
|
||||||
|
readonly property int fs_value: 36
|
||||||
|
readonly property int fs_unit: 18
|
||||||
|
readonly property int fs_btn: 15
|
||||||
|
readonly property int fs_btn_primary: 17
|
||||||
|
|
||||||
|
readonly property int fw_hero_title: 800
|
||||||
|
readonly property int fw_hero_sub: 650
|
||||||
|
readonly property int fw_panel_title: 800
|
||||||
|
readonly property int fw_metric: 700
|
||||||
|
readonly property int fw_value: 850
|
||||||
|
readonly property int fw_btn: 650
|
||||||
|
readonly property int fw_btn_primary: 800
|
||||||
|
|
||||||
|
readonly property real letter_spacing_panel: 1.0
|
||||||
|
|
||||||
|
// ── QSS (Qt Widgets) ────────────────────────────────────
|
||||||
|
readonly property string qss: `
|
||||||
|
QWidget {
|
||||||
|
background: ${root.bg_page};
|
||||||
|
color: ${root.fg_primary};
|
||||||
|
font-family: ${root.font_family};
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
QMainWindow,
|
||||||
|
QWidget#ClientRoot {
|
||||||
|
background: ${root.bg_page};
|
||||||
|
}
|
||||||
|
QFrame#HeroHeader,
|
||||||
|
QFrame#ClientPanel {
|
||||||
|
background: ${root.bg_card};
|
||||||
|
border: none;
|
||||||
|
border-radius: ${root.radius_panel}px;
|
||||||
|
}
|
||||||
|
QFrame#MetricCard {
|
||||||
|
background: ${root.bg_card_alt};
|
||||||
|
border: none;
|
||||||
|
border-radius: ${root.radius_card}px;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
background: ${root.bg_card};
|
||||||
|
border: none;
|
||||||
|
border-radius: ${root.radius_btn}px;
|
||||||
|
color: ${root.fg_secondary};
|
||||||
|
padding: ${root.padding_btn}px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: ${root.fw_btn};
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: ${root.border_btn_hv};
|
||||||
|
}
|
||||||
|
QPushButton#PrimaryButton {
|
||||||
|
background: ${root.fg_accent};
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: ${root.fs_btn_primary}px;
|
||||||
|
font-weight: ${root.fw_btn_primary};
|
||||||
|
}
|
||||||
|
QPushButton#PrimaryButton:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
QLabel#HeroTitle {
|
||||||
|
color: ${root.fg_primary};
|
||||||
|
font-size: ${root.fs_hero_title}px;
|
||||||
|
font-weight: ${root.fw_hero_title};
|
||||||
|
}
|
||||||
|
QLabel#HeroSubtitle {
|
||||||
|
color: ${root.fg_muted};
|
||||||
|
font-size: ${root.fs_hero_sub}px;
|
||||||
|
font-weight: ${root.fw_hero_sub};
|
||||||
|
}
|
||||||
|
QLabel#PanelTitle {
|
||||||
|
color: ${root.fg_secondary};
|
||||||
|
font-size: ${root.fs_panel_title}px;
|
||||||
|
font-weight: ${root.fw_panel_title};
|
||||||
|
letter-spacing: ${root.letter_spacing_panel}px;
|
||||||
|
}
|
||||||
|
QLabel#Metric {
|
||||||
|
color: ${root.fg_muted};
|
||||||
|
font-size: ${root.fs_metric}px;
|
||||||
|
font-weight: ${root.fw_metric};
|
||||||
|
}
|
||||||
|
QLabel#Value {
|
||||||
|
color: ${root.fg_primary};
|
||||||
|
font-size: ${root.fs_value}px;
|
||||||
|
font-weight: ${root.fw_value};
|
||||||
|
}
|
||||||
|
QLabel#Unit {
|
||||||
|
color: ${root.fg_muted};
|
||||||
|
font-size: ${root.fs_unit}px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
QLabel#SystemStatus {
|
||||||
|
color: ${root.fg_secondary};
|
||||||
|
font-size: ${root.fs_system_status}px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""PySide6 user interface helpers."""
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Qt import boundary with a helpful dependency error."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def require_qt():
|
||||||
|
try:
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
except ImportError as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
"PySide6 is required for the desktop apps. Install with: "
|
||||||
|
"python -m pip install -e ."
|
||||||
|
) from exc
|
||||||
|
return QtCore, QtGui, QtWidgets
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Load widget stylesheet and colors from Theme.qml."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
from PySide6.QtQml import QQmlComponent, QQmlEngine
|
||||||
|
|
||||||
|
_UI_DIR = Path(__file__).resolve().parent
|
||||||
|
_THEME_QML = _UI_DIR / "Theme.qml"
|
||||||
|
|
||||||
|
_engine: QQmlEngine | None = None
|
||||||
|
_root = None
|
||||||
|
|
||||||
|
|
||||||
|
def _pyside_qml_import_path() -> Path:
|
||||||
|
import PySide6
|
||||||
|
|
||||||
|
return Path(PySide6.__file__).resolve().parent / "Qt" / "qml"
|
||||||
|
|
||||||
|
|
||||||
|
def _theme_object():
|
||||||
|
global _engine, _root
|
||||||
|
if _root is not None:
|
||||||
|
return _root
|
||||||
|
|
||||||
|
engine = QQmlEngine()
|
||||||
|
engine.addImportPath(str(_pyside_qml_import_path()))
|
||||||
|
component = QQmlComponent(engine, QUrl.fromLocalFile(str(_THEME_QML)))
|
||||||
|
if component.isError():
|
||||||
|
errors = [error.toString() for error in component.errors()]
|
||||||
|
raise RuntimeError(f"Failed to load Theme.qml: {'; '.join(errors)}")
|
||||||
|
|
||||||
|
root = component.create()
|
||||||
|
if root is None:
|
||||||
|
raise RuntimeError("Theme.qml did not produce a root object")
|
||||||
|
root.setParent(engine)
|
||||||
|
|
||||||
|
_engine = engine
|
||||||
|
_root = root
|
||||||
|
return _root
|
||||||
|
|
||||||
|
|
||||||
|
def load_qss() -> str:
|
||||||
|
"""Return the Qt Widgets stylesheet defined in Theme.qml."""
|
||||||
|
return str(_theme_object().property("qss"))
|
||||||
|
|
||||||
|
|
||||||
|
def theme_color(name: str) -> str:
|
||||||
|
"""Return a theme color property as #rrggbb."""
|
||||||
|
value = _theme_object().property(name)
|
||||||
|
if hasattr(value, "name"):
|
||||||
|
return value.name()
|
||||||
|
return str(value)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Reusable Qt widgets for the wafer monitor demo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .qt import require_qt
|
||||||
|
|
||||||
|
|
||||||
|
QtCore, QtGui, QtWidgets = require_qt()
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
painter.setPen(QtGui.QPen(QtGui.QColor("#f97316"), 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(QtGui.QColor("#f97316"), 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)
|
||||||
|
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(20, 17, 20, 17)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
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
|
||||||
|
) -> 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("#f97316"), 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() - height - 4, 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)
|
||||||
Reference in New Issue
Block a user