Add initial project structure and configuration files

This commit is contained in:
Jack.Le
2026-05-18 14:14:16 -07:00
commit b9adc7c0b8
41 changed files with 94365 additions and 0 deletions
+31
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[General]
no-cmake-calls=true
importPaths=.venv/lib/python3.11/site-packages/PySide6/Qt/qml
+12
View File
@@ -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
View File
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
+39
View File
@@ -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.
![APP screenshot](screenshot.png)
+7
View File
@@ -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
}
+22
View File
@@ -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"]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

+187
View File
@@ -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())
+61
View File
@@ -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
+113
View File
@@ -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",
}
+240
View File
@@ -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]
+8
View File
@@ -0,0 +1,8 @@
"""Wafer edge monitor beta demo package."""
__all__ = [
"App",
"Calibration",
"VisionEngine",
"ui",
]
+150
View File
@@ -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;
}
`
}
+2
View File
@@ -0,0 +1,2 @@
"""PySide6 user interface helpers."""
+14
View File
@@ -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
+55
View File
@@ -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)
+198
View File
@@ -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)