update sensor bench
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""Sensor Bench: operator tooling for raw sensor inspection and recording."""
|
||||
"""Sensor Bench: public exhibition desktop app for Orisys tactile sensors."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -17,6 +17,8 @@ from typing import Callable, Optional
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FixedCameraMode:
|
||||
@@ -51,6 +53,7 @@ class SdkPacket:
|
||||
shear_y: float = 0.0
|
||||
session_id: int = 0
|
||||
mode: Optional[FixedCameraMode] = None
|
||||
flow: Optional[np.ndarray] = None
|
||||
|
||||
|
||||
def orisys_importable() -> tuple[bool, str]:
|
||||
@@ -104,6 +107,37 @@ def _ensure_bgr(img: np.ndarray) -> np.ndarray:
|
||||
return img
|
||||
|
||||
|
||||
ARROW_BG_BGR = (0x16, 0x13, 0x12) # #121316 as BGR
|
||||
_ARROW_DRAW_KW = dict(
|
||||
threshold=2,
|
||||
grid_spacing=20,
|
||||
arrow_scale=0.5,
|
||||
below_threshold_color=(160, 160, 160),
|
||||
)
|
||||
|
||||
|
||||
def draw_arrow_display(
|
||||
processed_frame: np.ndarray,
|
||||
flow: Optional[np.ndarray],
|
||||
*,
|
||||
background: str = "pure_color",
|
||||
) -> np.ndarray:
|
||||
"""Build exhibition display: arrows on solid bg or over the processed frame."""
|
||||
base = _ensure_bgr(processed_frame)
|
||||
if flow is None:
|
||||
return base
|
||||
try:
|
||||
import orisys
|
||||
|
||||
if background == "pure_color":
|
||||
canvas = np.empty_like(base)
|
||||
canvas[:, :] = ARROW_BG_BGR
|
||||
return orisys.util.draw_arrows(canvas, flow, **_ARROW_DRAW_KW)
|
||||
return orisys.util.draw_arrows(base, flow, **_ARROW_DRAW_KW)
|
||||
except Exception: # noqa: BLE001
|
||||
return base
|
||||
|
||||
|
||||
class SdkAcquisitionWorker:
|
||||
"""Background loop: ``Sensor.get_img()`` + deformation + contact."""
|
||||
|
||||
@@ -189,6 +223,14 @@ class SdkAcquisitionWorker:
|
||||
with self._lock:
|
||||
self._latest = None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the live Orisys sensor tracker (baseline), if running."""
|
||||
with self._lock:
|
||||
sensor = self._sensor
|
||||
if sensor is None:
|
||||
raise RuntimeError("Sensor is not running")
|
||||
sensor.reset()
|
||||
|
||||
def _close_sensor(self) -> None:
|
||||
sensor = self._sensor
|
||||
self._sensor = None
|
||||
@@ -220,6 +262,10 @@ class SdkAcquisitionWorker:
|
||||
if self._cal_path:
|
||||
kwargs["cal_path"] = self._cal_path
|
||||
self._sensor = orisys.Sensor(source, **kwargs)
|
||||
self._sensor.configure(
|
||||
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||
)
|
||||
self._status(f"Streaming {mode.label()}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
@@ -252,14 +298,23 @@ class SdkAcquisitionWorker:
|
||||
fps = float(len(frame_times)) if frame_times else 0.0
|
||||
|
||||
fn = fx = fy = 0.0
|
||||
flow = None
|
||||
try:
|
||||
fn, fx, fy = self._sensor.read_info(
|
||||
fn, fx, fy, flow = self._sensor.read_info(
|
||||
self._sensor.info.FNORMAL,
|
||||
self._sensor.info.FSHEARX,
|
||||
self._sensor.info.FSHEARY,
|
||||
self._sensor.info.VRAW,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
fn, fx, fy = self._sensor.read_info(
|
||||
self._sensor.info.FNORMAL,
|
||||
self._sensor.info.FSHEARX,
|
||||
self._sensor.info.FSHEARY,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
raw_bgr = _ensure_bgr(raw)
|
||||
proc_bgr = _ensure_bgr(processed)
|
||||
@@ -278,6 +333,7 @@ class SdkAcquisitionWorker:
|
||||
shear_y=float(fy),
|
||||
session_id=session,
|
||||
mode=mode,
|
||||
flow=flow,
|
||||
)
|
||||
with self._lock:
|
||||
self._latest = packet
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
"""Discover USB cameras for Sensor Bench (Windows DSHOW/MSMF, Linux V4L2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
|
||||
DEFAULT_MAX_CAMERA_INDEX = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraInfo:
|
||||
index: int
|
||||
backend_name: str
|
||||
backend_api: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return str(self.index)
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _suppress_opencv_noise():
|
||||
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
|
||||
prev_level = None
|
||||
set_level = None
|
||||
try:
|
||||
logging = getattr(getattr(cv2, "utils", None), "logging", None)
|
||||
if logging is not None:
|
||||
set_level = getattr(logging, "setLogLevel", None)
|
||||
get_level = getattr(logging, "getLogLevel", None)
|
||||
silent = getattr(logging, "LOG_LEVEL_SILENT", 0)
|
||||
if set_level is not None:
|
||||
if get_level is not None:
|
||||
prev_level = get_level()
|
||||
set_level(silent)
|
||||
except Exception:
|
||||
prev_level = None
|
||||
|
||||
devnull = open(os.devnull, "w")
|
||||
try:
|
||||
with contextlib.redirect_stderr(devnull):
|
||||
stderr_fd = sys.stderr.fileno()
|
||||
saved_fd = os.dup(stderr_fd)
|
||||
try:
|
||||
os.dup2(devnull.fileno(), stderr_fd)
|
||||
yield
|
||||
finally:
|
||||
os.dup2(saved_fd, stderr_fd)
|
||||
os.close(saved_fd)
|
||||
finally:
|
||||
devnull.close()
|
||||
if set_level is not None and prev_level is not None:
|
||||
try:
|
||||
set_level(prev_level)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _backend_candidates() -> list[tuple[str, int]]:
|
||||
candidates: list[tuple[str, int]] = []
|
||||
if sys.platform == "win32":
|
||||
if hasattr(cv2, "CAP_DSHOW"):
|
||||
candidates.append(("DSHOW", cv2.CAP_DSHOW))
|
||||
if hasattr(cv2, "CAP_MSMF"):
|
||||
candidates.append(("MSMF", cv2.CAP_MSMF))
|
||||
elif sys.platform.startswith("linux"):
|
||||
if hasattr(cv2, "CAP_V4L2"):
|
||||
candidates.append(("V4L2", cv2.CAP_V4L2))
|
||||
if not candidates:
|
||||
candidates.append(("ANY", cv2.CAP_ANY))
|
||||
return candidates
|
||||
|
||||
|
||||
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
|
||||
cap = cv2.VideoCapture(index, backend_api)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
return None
|
||||
ok, frame = cap.read()
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||
if not ok or frame is None:
|
||||
if width <= 0 or height <= 0:
|
||||
cap.release()
|
||||
return None
|
||||
else:
|
||||
h, w = frame.shape[:2]
|
||||
width = width or int(w)
|
||||
height = height or int(h)
|
||||
cap.release()
|
||||
return CameraInfo(
|
||||
index=index,
|
||||
backend_name=backend_name,
|
||||
backend_api=backend_api,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
|
||||
def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
|
||||
"""Probe indices ``0 .. max_index`` and return working cameras."""
|
||||
found: list[CameraInfo] = []
|
||||
seen_indices: set[int] = set()
|
||||
backends = _backend_candidates()
|
||||
|
||||
with _suppress_opencv_noise():
|
||||
for index in range(0, max_index + 1):
|
||||
for backend_name, backend_api in backends:
|
||||
info = _try_open(index, backend_name, backend_api)
|
||||
if info is None:
|
||||
continue
|
||||
if index in seen_indices:
|
||||
break
|
||||
found.append(info)
|
||||
seen_indices.add(index)
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
|
||||
if not cameras:
|
||||
return None
|
||||
preferred = (preferred or "").strip()
|
||||
if preferred:
|
||||
for cam in cameras:
|
||||
if cam.source == preferred:
|
||||
return cam.source
|
||||
return cameras[0].source
|
||||
@@ -0,0 +1,243 @@
|
||||
"""DD06 acquisition: external capture + ROI rectify + already_processed Sensor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .acquisition import FixedCameraMode, SdkPacket, _ensure_bgr, load_fixed_mode, orisys_importable
|
||||
from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
|
||||
from .roi_geometry import (
|
||||
build_roi_warp,
|
||||
load_json_config,
|
||||
load_roi_polygon,
|
||||
open_video_source,
|
||||
rectified_output_size,
|
||||
warp_roi_to_rectangle,
|
||||
)
|
||||
|
||||
|
||||
class Dd06AcquisitionWorker:
|
||||
"""Background loop for DD06 polygon-ROI rectification pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
on_status: Optional[Callable[[str], None]] = None,
|
||||
):
|
||||
self._on_error = on_error
|
||||
self._on_status = on_status
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._latest: Optional[SdkPacket] = None
|
||||
self._session_id = 0
|
||||
self._sensor = None
|
||||
self._cap = None
|
||||
self._mode: Optional[FixedCameraMode] = None
|
||||
self._video_source: Optional[str | int] = None
|
||||
self._config_ref: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def session_id(self) -> int:
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def mode(self) -> Optional[FixedCameraMode]:
|
||||
return self._mode
|
||||
|
||||
def latest(self) -> Optional[SdkPacket]:
|
||||
with self._lock:
|
||||
return self._latest
|
||||
|
||||
def start(
|
||||
self,
|
||||
video_source: str | int = 0,
|
||||
*,
|
||||
config_path: str | Path,
|
||||
) -> FixedCameraMode:
|
||||
self.stop()
|
||||
ok, detail = orisys_importable()
|
||||
if not ok:
|
||||
raise RuntimeError(
|
||||
"orisys SDK is not importable. Install orisys and ensure it is available. "
|
||||
f"Detail: {detail}"
|
||||
)
|
||||
mode = load_fixed_mode(config_path)
|
||||
self._mode = mode
|
||||
self._video_source = video_source
|
||||
self._config_ref = str(config_path).strip()
|
||||
self._latest = None
|
||||
self._session_id += 1
|
||||
session = self._session_id
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name=f"Dd06Acquisition-{session}",
|
||||
daemon=True,
|
||||
args=(session,),
|
||||
)
|
||||
self._thread.start()
|
||||
return self._mode
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=5.0)
|
||||
self._thread = None
|
||||
self._close()
|
||||
with self._lock:
|
||||
self._latest = None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the live Orisys sensor tracker (baseline), if running."""
|
||||
with self._lock:
|
||||
sensor = self._sensor
|
||||
if sensor is None:
|
||||
raise RuntimeError("Sensor is not running")
|
||||
sensor.reset()
|
||||
|
||||
def _close(self) -> None:
|
||||
cap = self._cap
|
||||
self._cap = None
|
||||
if cap is not None:
|
||||
try:
|
||||
cap.release()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
sensor = self._sensor
|
||||
self._sensor = None
|
||||
if sensor is not None:
|
||||
try:
|
||||
sensor.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _status(self, message: str) -> None:
|
||||
if self._on_status is not None:
|
||||
self._on_status(message)
|
||||
|
||||
def _run(self, session: int) -> None:
|
||||
import orisys
|
||||
|
||||
mode = self._mode
|
||||
source = self._video_source
|
||||
config_ref = self._config_ref
|
||||
if mode is None or source is None or config_ref is None:
|
||||
return
|
||||
try:
|
||||
self._status(f"Opening DD06 pipeline ({mode.width}x{mode.height})…")
|
||||
config = load_json_config(config_ref)
|
||||
roi_points, roi_image_size = load_roi_polygon(config)
|
||||
out_size = rectified_output_size(config, roi_points)
|
||||
self._sensor = orisys.Sensor("external", config_name=config_ref, verbose=False)
|
||||
self._sensor.configure(
|
||||
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||
)
|
||||
self._cap, resolved = open_video_source(source)
|
||||
warp_matrix = None
|
||||
last_frame_size = None
|
||||
self._status(f"Streaming DD06 ROI {out_size[0]}x{out_size[1]} from {resolved}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
self._on_error(str(exc))
|
||||
self._close()
|
||||
return
|
||||
|
||||
frame_times: list[float] = []
|
||||
while not self._stop.is_set() and session == self._session_id:
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
ok, frame = self._cap.read()
|
||||
if not ok or frame is None:
|
||||
time.sleep(0.005)
|
||||
continue
|
||||
|
||||
frame_h, frame_w = frame.shape[:2]
|
||||
frame_size = (frame_w, frame_h)
|
||||
if warp_matrix is None or last_frame_size != frame_size:
|
||||
warp_matrix, _, _ = build_roi_warp(
|
||||
roi_points,
|
||||
roi_image_size,
|
||||
frame_size,
|
||||
out_size,
|
||||
)
|
||||
last_frame_size = frame_size
|
||||
|
||||
rectified = warp_roi_to_rectangle(frame, warp_matrix, out_size)
|
||||
processed = self._sensor.get_img(frame=rectified, already_processed=True)
|
||||
if processed is None:
|
||||
processed = rectified
|
||||
|
||||
try:
|
||||
self._sensor.compute_deformation(decompose=True, check_motion=False)
|
||||
self._sensor.compute_contact()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
t1 = time.perf_counter()
|
||||
frame_times.append(t1)
|
||||
cutoff = t1 - 1.0
|
||||
frame_times = [t for t in frame_times if t >= cutoff]
|
||||
fps = float(len(frame_times)) if frame_times else 0.0
|
||||
|
||||
fn = fx = fy = 0.0
|
||||
flow = None
|
||||
try:
|
||||
fn, fx, fy, flow = self._sensor.read_info(
|
||||
self._sensor.info.FNORMAL,
|
||||
self._sensor.info.FSHEARX,
|
||||
self._sensor.info.FSHEARY,
|
||||
self._sensor.info.VRAW,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
fn, fx, fy = self._sensor.read_info(
|
||||
self._sensor.info.FNORMAL,
|
||||
self._sensor.info.FSHEARX,
|
||||
self._sensor.info.FSHEARY,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
raw_bgr = _ensure_bgr(frame)
|
||||
# Prefer sensor.img / get_img result for processed visualization.
|
||||
proc_src = getattr(self._sensor, "img", None)
|
||||
if proc_src is None:
|
||||
proc_src = processed
|
||||
proc_bgr = _ensure_bgr(proc_src)
|
||||
packet = SdkPacket(
|
||||
raw_frame=raw_bgr,
|
||||
processed_frame=proc_bgr,
|
||||
timestamp=time.time(),
|
||||
fps=fps,
|
||||
process_ms=(t1 - t0) * 1000.0,
|
||||
width=int(raw_bgr.shape[1]),
|
||||
height=int(raw_bgr.shape[0]),
|
||||
processed_width=int(proc_bgr.shape[1]),
|
||||
processed_height=int(proc_bgr.shape[0]),
|
||||
normal_force=float(fn),
|
||||
shear_x=float(fx),
|
||||
shear_y=float(fy),
|
||||
session_id=session,
|
||||
mode=mode,
|
||||
flow=flow,
|
||||
)
|
||||
with self._lock:
|
||||
self._latest = packet
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
self._on_error(str(exc))
|
||||
break
|
||||
|
||||
self._close()
|
||||
self._status("Acquisition stopped")
|
||||
@@ -0,0 +1,910 @@
|
||||
"""Exhibition-friendly Sensor Bench window (qt_viewer_2-style layout)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from importlib.metadata import PackageNotFoundError, version as pkg_version
|
||||
from pathlib import Path
|
||||
from typing import Optional, Protocol
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, QObject, QTimer, pyqtSignal
|
||||
from PyQt5.QtGui import QImage, QPixmap
|
||||
from PyQt5.QtWidgets import (
|
||||
QAction,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise ImportError(
|
||||
"PyQtGraph is required. Install with: pip install pyqt5 pyqtgraph"
|
||||
) from exc
|
||||
|
||||
from .acquisition import (
|
||||
FixedCameraMode,
|
||||
SdkAcquisitionWorker,
|
||||
SdkPacket,
|
||||
draw_arrow_display,
|
||||
load_fixed_mode,
|
||||
orisys_importable,
|
||||
)
|
||||
from .camera_discovery import list_cameras, pick_default_camera
|
||||
from .dd06_acquisition import Dd06AcquisitionWorker
|
||||
from .force_calibration import (
|
||||
FORCE_SAMPLE_RATE_HZ,
|
||||
PLOT_BUFFER_POINTS,
|
||||
PLOT_WINDOW_SEC,
|
||||
Y_AXIS_MIN_SPAN,
|
||||
Y_RANGE_ALPHA,
|
||||
calibrate_normal_force,
|
||||
)
|
||||
from .i18n import DEFAULT_LOCALE, Translator
|
||||
from .paths import (
|
||||
config_display_name,
|
||||
default_dd06_config,
|
||||
default_ddjx01_config,
|
||||
default_locales_dir,
|
||||
default_recordings_dir,
|
||||
list_orisys_configs,
|
||||
orisys_configs_dir,
|
||||
project_root,
|
||||
)
|
||||
from .plot_history import MetricHistory
|
||||
from .recorder import RawRecorder
|
||||
|
||||
CURVE_COLORS = ("#8ab4f8", "#34a853", "#fbbc04")
|
||||
APP_VERSION_FALLBACK = "0.1.0"
|
||||
|
||||
STYLE = """
|
||||
QMainWindow, QWidget {
|
||||
background-color: #1e1f22;
|
||||
color: #e8eaed;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", "SimHei", Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
QMenuBar {
|
||||
background-color: #1e1f22;
|
||||
color: #e8eaed;
|
||||
}
|
||||
QMenuBar::item:selected { background-color: #2d3139; }
|
||||
QMenu {
|
||||
background-color: #2a2d33;
|
||||
color: #e8eaed;
|
||||
border: 1px solid #4a505a;
|
||||
}
|
||||
QMenu::item:selected { background-color: #243447; }
|
||||
QPushButton {
|
||||
background-color: #2d3139;
|
||||
border: 1px solid #4a505a;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
min-height: 28px;
|
||||
color: #e8eaed;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a404c; }
|
||||
QPushButton:pressed { background-color: #252930; }
|
||||
QPushButton:disabled { color: #6b7280; background-color: #25262a; }
|
||||
QPushButton#btnPrimary {
|
||||
background-color: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton#btnPrimary:hover { background-color: #1967d2; }
|
||||
QPushButton#btnDanger {
|
||||
background-color: #c5221f;
|
||||
border-color: #c5221f;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton#btnDanger:hover { background-color: #a50e0e; }
|
||||
QLineEdit, QComboBox {
|
||||
background-color: #2a2d33;
|
||||
border: 1px solid #4a505a;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
color: #e8eaed;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #2a2d33;
|
||||
color: #e8eaed;
|
||||
selection-background-color: #243447;
|
||||
}
|
||||
QLabel#toolbarCaption { color: #9aa0a6; }
|
||||
QLabel#imagePanel {
|
||||
color: #6b7280;
|
||||
background-color: #121316;
|
||||
border: 1px solid #3c4048;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QPlainTextEdit {
|
||||
background-color: #121316;
|
||||
color: #9aa0a6;
|
||||
border: 1px solid #3c4048;
|
||||
border-radius: 6px;
|
||||
font-family: "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
QSplitter::handle { background-color: #2a2d33; }
|
||||
"""
|
||||
|
||||
|
||||
class AcquisitionPipeline(Protocol):
|
||||
is_running: bool
|
||||
|
||||
def latest(self) -> Optional[SdkPacket]: ...
|
||||
|
||||
def stop(self) -> None: ...
|
||||
|
||||
def reset(self) -> None: ...
|
||||
|
||||
|
||||
def ndarray_to_pixmap(frame: np.ndarray) -> QPixmap:
|
||||
if frame.ndim == 2 or (frame.ndim == 3 and frame.shape[2] == 1):
|
||||
gray = frame if frame.ndim == 2 else frame[:, :, 0]
|
||||
h, w = gray.shape[:2]
|
||||
image = QImage(gray.data, w, h, w, QImage.Format_Grayscale8)
|
||||
return QPixmap.fromImage(image.copy())
|
||||
if frame.ndim == 3 and frame.shape[2] == 4:
|
||||
rgba = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGBA)
|
||||
h, w = rgba.shape[:2]
|
||||
image = QImage(rgba.data, w, h, w * 4, QImage.Format_RGBA8888)
|
||||
return QPixmap.fromImage(image.copy())
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
h, w = rgb.shape[:2]
|
||||
image = QImage(rgb.data, w, h, w * 3, QImage.Format_RGB888)
|
||||
return QPixmap.fromImage(image.copy())
|
||||
|
||||
|
||||
def app_version() -> str:
|
||||
try:
|
||||
return pkg_version("sensor-bench")
|
||||
except PackageNotFoundError:
|
||||
return APP_VERSION_FALLBACK
|
||||
|
||||
|
||||
class _AcquisitionBridge(QObject):
|
||||
error = pyqtSignal(str)
|
||||
status = pyqtSignal(str)
|
||||
|
||||
|
||||
class ExhibitionWindow(QMainWindow):
|
||||
"""External exhibition UI: toolbar + image + FN/FX/FY plots + log."""
|
||||
|
||||
def __init__(self, *, locales_dir: Optional[Path] = None, locale: str = DEFAULT_LOCALE):
|
||||
super().__init__()
|
||||
self.resize(1480, 900)
|
||||
self.setStyleSheet(STYLE)
|
||||
|
||||
self.tr = Translator(locales_dir or default_locales_dir(), locale=locale)
|
||||
self.recordings_dir = default_recordings_dir()
|
||||
self.force_history = MetricHistory(
|
||||
npoints=PLOT_BUFFER_POINTS,
|
||||
channels=3,
|
||||
window_sec=PLOT_WINDOW_SEC,
|
||||
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||
y_axis_min_span=Y_AXIS_MIN_SPAN,
|
||||
y_range_alpha=Y_RANGE_ALPHA,
|
||||
)
|
||||
|
||||
self._acq_bridge = _AcquisitionBridge()
|
||||
self._acq_bridge.error.connect(self._on_acquisition_error)
|
||||
self._acq_bridge.status.connect(self._on_acquisition_status)
|
||||
self.stitch_worker = SdkAcquisitionWorker(
|
||||
on_error=lambda m: self._acq_bridge.error.emit(m),
|
||||
on_status=lambda m: self._acq_bridge.status.emit(m),
|
||||
)
|
||||
self.dd06_worker = Dd06AcquisitionWorker(
|
||||
on_error=lambda m: self._acq_bridge.error.emit(m),
|
||||
on_status=lambda m: self._acq_bridge.status.emit(m),
|
||||
)
|
||||
self.acquisition: AcquisitionPipeline = self.stitch_worker
|
||||
self.recorder = RawRecorder()
|
||||
|
||||
self._source_pixmap: Optional[QPixmap] = None
|
||||
self._last_packet: Optional[SdkPacket] = None
|
||||
self._last_packet_id = 0
|
||||
self._awaiting_first_frame = False
|
||||
self._display_aspect = 1.0
|
||||
self._view_mode = "pure_color"
|
||||
self._sensor_type = "orisys"
|
||||
self._log_panel_visible = False
|
||||
self._fixed_mode: Optional[FixedCameraMode] = None
|
||||
self._orisys_config = str(default_ddjx01_config())
|
||||
self._dd06_config = str(default_dd06_config())
|
||||
|
||||
self._build_ui()
|
||||
self._create_plots()
|
||||
self._apply_language()
|
||||
self._refresh_orisys_status()
|
||||
self._log(self.t("exhibition.hints"))
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self._on_tick)
|
||||
self.timer.start(16)
|
||||
|
||||
def t(self, key: str, **kwargs) -> str:
|
||||
return self.tr.t(key, **kwargs)
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
stamp = time.strftime("%H:%M:%S")
|
||||
self.log_widget.appendPlainText(f"[{stamp}] {message}")
|
||||
|
||||
def _pipeline_running(self) -> bool:
|
||||
return bool(self.stitch_worker.is_running or self.dd06_worker.is_running)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self._build_menu()
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root = QVBoxLayout(central)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(8)
|
||||
|
||||
self.sensor_caption = QLabel()
|
||||
self.sensor_caption.setObjectName("toolbarCaption")
|
||||
bar.addWidget(self.sensor_caption)
|
||||
self.sensor_combo = QComboBox()
|
||||
self.sensor_combo.addItem("", "orisys")
|
||||
self.sensor_combo.addItem("", "dd06")
|
||||
self.sensor_combo.currentIndexChanged.connect(self._on_sensor_type_changed)
|
||||
bar.addWidget(self.sensor_combo)
|
||||
|
||||
self.btn_search = QPushButton()
|
||||
self.btn_search.clicked.connect(self._on_search_cameras)
|
||||
bar.addWidget(self.btn_search)
|
||||
self.btn_camera = QPushButton()
|
||||
self.btn_camera.clicked.connect(self._on_browse_camera)
|
||||
bar.addWidget(self.btn_camera)
|
||||
self.camera_edit = QLineEdit("0")
|
||||
bar.addWidget(self.camera_edit, 1)
|
||||
|
||||
self.btn_config = QPushButton()
|
||||
self.btn_config.clicked.connect(self._on_browse_config)
|
||||
bar.addWidget(self.btn_config)
|
||||
self.config_combo = QComboBox()
|
||||
self.config_combo.setMinimumWidth(160)
|
||||
self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
bar.addWidget(self.config_combo, 1)
|
||||
self._populate_config_combo(preferred=self._orisys_config)
|
||||
|
||||
self.btn_start = QPushButton()
|
||||
self.btn_start.setObjectName("btnPrimary")
|
||||
self.btn_start.clicked.connect(self._on_start)
|
||||
bar.addWidget(self.btn_start)
|
||||
|
||||
self.btn_stop = QPushButton()
|
||||
self.btn_stop.setEnabled(False)
|
||||
self.btn_stop.clicked.connect(self._on_stop)
|
||||
bar.addWidget(self.btn_stop)
|
||||
|
||||
self.btn_reset = QPushButton()
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_reset.clicked.connect(self._on_reset)
|
||||
bar.addWidget(self.btn_reset)
|
||||
|
||||
self.btn_record = QPushButton()
|
||||
self.btn_record.setObjectName("btnDanger")
|
||||
self.btn_record.setEnabled(False)
|
||||
self.btn_record.clicked.connect(self._on_toggle_record)
|
||||
bar.addWidget(self.btn_record)
|
||||
|
||||
self.show_log_check = QCheckBox()
|
||||
self.show_log_check.setChecked(False)
|
||||
self.show_log_check.toggled.connect(self._on_show_log_changed)
|
||||
bar.addWidget(self.show_log_check)
|
||||
|
||||
self.view_caption = QLabel()
|
||||
self.view_caption.setObjectName("toolbarCaption")
|
||||
bar.addWidget(self.view_caption)
|
||||
self.view_combo = QComboBox()
|
||||
self.view_combo.addItem("", "pure_color")
|
||||
self.view_combo.addItem("", "captured")
|
||||
self.view_combo.addItem("", "raw")
|
||||
self.view_combo.setCurrentIndex(0)
|
||||
self.view_combo.currentIndexChanged.connect(self._on_view_changed)
|
||||
bar.addWidget(self.view_combo)
|
||||
|
||||
self.language_caption = QLabel()
|
||||
self.language_caption.setObjectName("toolbarCaption")
|
||||
bar.addWidget(self.language_caption)
|
||||
self.language_combo = QComboBox()
|
||||
self.language_combo.addItem("", "zh_CN")
|
||||
self.language_combo.addItem("", "en_US")
|
||||
self.language_combo.setCurrentIndex(0 if self.tr.locale == "zh_CN" else 1)
|
||||
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
|
||||
bar.addWidget(self.language_combo)
|
||||
|
||||
root.addLayout(bar)
|
||||
|
||||
v_splitter = QSplitter(Qt.Vertical)
|
||||
h_splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.image_label = QLabel()
|
||||
self.image_label.setObjectName("imagePanel")
|
||||
self.image_label.setMinimumSize(240, 240)
|
||||
self.image_label.setAlignment(Qt.AlignCenter)
|
||||
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
left_layout.addWidget(self.image_label)
|
||||
h_splitter.addWidget(left)
|
||||
|
||||
pg.setConfigOptions(antialias=True, foreground="#e8eaed", background="#121316")
|
||||
self.graph_widget = pg.GraphicsLayoutWidget()
|
||||
self.graph_widget.setMinimumWidth(320)
|
||||
h_splitter.addWidget(self.graph_widget)
|
||||
self.h_splitter = h_splitter
|
||||
v_splitter.addWidget(h_splitter)
|
||||
|
||||
self.log_widget = QPlainTextEdit()
|
||||
self.log_widget.setReadOnly(True)
|
||||
self.log_widget.setMaximumBlockCount(500)
|
||||
self.log_widget.setMinimumHeight(110)
|
||||
self.log_widget.setVisible(self._log_panel_visible)
|
||||
v_splitter.addWidget(self.log_widget)
|
||||
v_splitter.setStretchFactor(0, 5)
|
||||
v_splitter.setStretchFactor(1, 1)
|
||||
root.addWidget(v_splitter, 1)
|
||||
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setStyleSheet("color: #9aa0a6;")
|
||||
root.addWidget(self.status_label)
|
||||
|
||||
def _build_menu(self) -> None:
|
||||
menu = self.menuBar()
|
||||
self.help_menu = menu.addMenu("")
|
||||
self.about_action = QAction("", self)
|
||||
self.about_action.triggered.connect(self._on_about)
|
||||
self.help_menu.addAction(self.about_action)
|
||||
|
||||
def _create_plots(self) -> None:
|
||||
self.plots = []
|
||||
self.curves = []
|
||||
self.dot_cores = []
|
||||
self.plot_titles = [
|
||||
"exhibition.plot_fn",
|
||||
"exhibition.plot_fx",
|
||||
"exhibition.plot_fy",
|
||||
]
|
||||
axis_pen = pg.mkPen(color="#3c4048", width=1)
|
||||
for i, title_key in enumerate(self.plot_titles):
|
||||
plot = self.graph_widget.addPlot(title=self.t(title_key))
|
||||
plot.showGrid(x=False, y=True, alpha=0.2)
|
||||
plot.getAxis("left").setPen(axis_pen)
|
||||
plot.getAxis("bottom").setPen(axis_pen)
|
||||
plot.setLabel("bottom", "s")
|
||||
curve = plot.plot(pen=pg.mkPen(CURVE_COLORS[i], width=2))
|
||||
dot = plot.plot(
|
||||
[],
|
||||
[],
|
||||
pen=None,
|
||||
symbol="o",
|
||||
symbolSize=6,
|
||||
symbolBrush=CURVE_COLORS[i],
|
||||
)
|
||||
plot.setXRange(0, PLOT_WINDOW_SEC, padding=0)
|
||||
plot.enableAutoRange(x=False, y=False)
|
||||
self.plots.append(plot)
|
||||
self.curves.append(curve)
|
||||
self.dot_cores.append(dot)
|
||||
if i < 2:
|
||||
self.graph_widget.nextRow()
|
||||
|
||||
def _apply_language(self) -> None:
|
||||
self.setWindowTitle(self.t("exhibition.title"))
|
||||
self.help_menu.setTitle(self.t("exhibition.menu_help"))
|
||||
self.about_action.setText(self.t("exhibition.menu_about"))
|
||||
self.sensor_caption.setText(self.t("exhibition.sensor_type"))
|
||||
self.btn_search.setText(self.t("exhibition.btn_search"))
|
||||
self.btn_camera.setText(self.t("exhibition.btn_camera"))
|
||||
self.btn_config.setText(self.t("exhibition.btn_config"))
|
||||
self.btn_start.setText(self.t("exhibition.btn_start"))
|
||||
self.btn_stop.setText(self.t("exhibition.btn_stop"))
|
||||
self.btn_reset.setText(self.t("exhibition.btn_reset"))
|
||||
self._refresh_record_button()
|
||||
self.view_caption.setText(self.t("exhibition.view"))
|
||||
self.show_log_check.setText(self.t("exhibition.show_log"))
|
||||
self.language_caption.setText(self.t("app.language"))
|
||||
self.camera_edit.setPlaceholderText(self.t("exhibition.placeholder_camera"))
|
||||
self._update_config_tooltip()
|
||||
|
||||
self.language_combo.blockSignals(True)
|
||||
self.language_combo.setItemText(0, self.t("app.lang_zh"))
|
||||
self.language_combo.setItemText(1, self.t("app.lang_en"))
|
||||
self.language_combo.blockSignals(False)
|
||||
|
||||
self.sensor_combo.blockSignals(True)
|
||||
self.sensor_combo.setItemText(0, self.t("exhibition.sensor_orisys"))
|
||||
self.sensor_combo.setItemText(1, self.t("exhibition.sensor_dd06"))
|
||||
self.sensor_combo.blockSignals(False)
|
||||
|
||||
self.view_combo.blockSignals(True)
|
||||
self.view_combo.setItemText(0, self.t("exhibition.view_pure_color"))
|
||||
self.view_combo.setItemText(1, self.t("exhibition.view_captured"))
|
||||
self.view_combo.setItemText(2, self.t("exhibition.view_raw"))
|
||||
self.view_combo.blockSignals(False)
|
||||
|
||||
for plot, title_key in zip(self.plots, self.plot_titles):
|
||||
plot.setTitle(self.t(title_key), color="#9aa0a6", size="12pt")
|
||||
|
||||
if self._source_pixmap is None:
|
||||
self.image_label.setText(self.t("labels.no_preview"))
|
||||
self.status_label.setText(self.t("status.ready"))
|
||||
|
||||
def _selected_config(self) -> str:
|
||||
data = self.config_combo.currentData()
|
||||
if data:
|
||||
return str(data)
|
||||
return self.config_combo.currentText().strip()
|
||||
|
||||
def _set_config_selection(self, value: str) -> None:
|
||||
value = str(value or "").strip()
|
||||
if not value:
|
||||
return
|
||||
for index in range(self.config_combo.count()):
|
||||
if str(self.config_combo.itemData(index) or "") == value:
|
||||
self.config_combo.setCurrentIndex(index)
|
||||
return
|
||||
# Custom path not yet in the list.
|
||||
if Path(value).stem.lower() == "options" and Path(value).suffix.lower() == ".json":
|
||||
return
|
||||
self.config_combo.addItem(config_display_name(value), value)
|
||||
self.config_combo.setCurrentIndex(self.config_combo.count() - 1)
|
||||
|
||||
def _populate_config_combo(self, *, preferred: str | None = None) -> None:
|
||||
current = preferred or self._selected_config() or default_ddjx01_config()
|
||||
custom_paths = []
|
||||
for index in range(self.config_combo.count()):
|
||||
data = str(self.config_combo.itemData(index) or "")
|
||||
if data and (Path(data).suffix.lower() == ".json" or "/" in data or "\\" in data):
|
||||
if data not in custom_paths:
|
||||
custom_paths.append(data)
|
||||
|
||||
self.config_combo.blockSignals(True)
|
||||
self.config_combo.clear()
|
||||
for entry in list_orisys_configs():
|
||||
self.config_combo.addItem(config_display_name(entry), entry)
|
||||
for path in custom_paths:
|
||||
# Avoid duplicating local entries already listed above.
|
||||
if any(str(self.config_combo.itemData(i) or "") == path for i in range(self.config_combo.count())):
|
||||
continue
|
||||
if Path(path).stem.lower() == "options":
|
||||
continue
|
||||
self.config_combo.addItem(config_display_name(path), path)
|
||||
if self.config_combo.count() == 0 and current:
|
||||
self.config_combo.addItem(config_display_name(current), current)
|
||||
self.config_combo.blockSignals(False)
|
||||
self._set_config_selection(current)
|
||||
self._update_config_tooltip()
|
||||
|
||||
def _update_config_tooltip(self) -> None:
|
||||
cfg_dir = orisys_configs_dir()
|
||||
selected = self._selected_config()
|
||||
if cfg_dir is not None:
|
||||
tip = self.t("exhibition.config_dir_tip", path=cfg_dir, selected=selected)
|
||||
else:
|
||||
tip = self.t("exhibition.config_dir_missing")
|
||||
self.config_combo.setToolTip(tip)
|
||||
self.btn_config.setToolTip(tip)
|
||||
|
||||
def _refresh_record_button(self) -> None:
|
||||
if self.recorder.is_recording:
|
||||
self.btn_record.setText(self.t("exhibition.btn_stop_record"))
|
||||
else:
|
||||
self.btn_record.setText(self.t("exhibition.btn_start_record"))
|
||||
|
||||
def _on_about(self) -> None:
|
||||
ok, orisys_ver = orisys_importable()
|
||||
orisys_text = orisys_ver if ok else self.t("status.orisys_missing")
|
||||
QMessageBox.about(
|
||||
self,
|
||||
self.t("exhibition.about_title"),
|
||||
self.t(
|
||||
"exhibition.about_body",
|
||||
app_version=app_version(),
|
||||
orisys_version=orisys_text,
|
||||
),
|
||||
)
|
||||
|
||||
def _on_language_changed(self, *_args) -> None:
|
||||
locale = self.language_combo.currentData()
|
||||
if not locale:
|
||||
return
|
||||
self.tr.set_locale(str(locale))
|
||||
self._apply_language()
|
||||
self._refresh_orisys_status()
|
||||
|
||||
def _on_sensor_type_changed(self, *_args) -> None:
|
||||
if self._pipeline_running():
|
||||
return
|
||||
kind = str(self.sensor_combo.currentData() or "orisys")
|
||||
if kind == self._sensor_type:
|
||||
return
|
||||
# Remember current config for the leaving type.
|
||||
if self._sensor_type == "orisys":
|
||||
self._orisys_config = self._selected_config() or self._orisys_config
|
||||
else:
|
||||
self._dd06_config = self._selected_config() or self._dd06_config
|
||||
self._sensor_type = kind
|
||||
self.acquisition = self.dd06_worker if kind == "dd06" else self.stitch_worker
|
||||
preferred = self._dd06_config if kind == "dd06" else self._orisys_config
|
||||
self._populate_config_combo(preferred=preferred)
|
||||
self._log(self.t("exhibition.sensor_switched", sensor=kind))
|
||||
|
||||
def _on_view_changed(self, *_args) -> None:
|
||||
mode = self.view_combo.currentData()
|
||||
if mode in ("pure_color", "captured", "raw"):
|
||||
self._view_mode = str(mode)
|
||||
if self._last_packet is not None:
|
||||
self._update_image(self._last_packet)
|
||||
|
||||
def _on_show_log_changed(self, visible: bool) -> None:
|
||||
self._log_panel_visible = bool(visible)
|
||||
self.log_widget.setVisible(self._log_panel_visible)
|
||||
|
||||
def _refresh_orisys_status(self) -> None:
|
||||
ok, detail = orisys_importable()
|
||||
if ok:
|
||||
self.status_label.setText(self.t("status.orisys_ok", version=detail))
|
||||
self.btn_start.setEnabled(not self._pipeline_running())
|
||||
self._populate_config_combo(preferred=self._selected_config() or default_ddjx01_config())
|
||||
else:
|
||||
self.status_label.setText(self.t("status.orisys_missing"))
|
||||
self.btn_start.setEnabled(False)
|
||||
self._update_config_tooltip()
|
||||
|
||||
def _parse_camera_source(self) -> str | int:
|
||||
text = self.camera_edit.text().strip() or "0"
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
return text
|
||||
|
||||
def _on_search_cameras(self) -> None:
|
||||
if self._pipeline_running():
|
||||
return
|
||||
preferred = self.camera_edit.text().strip()
|
||||
cameras = list_cameras()
|
||||
if not cameras:
|
||||
self._log(self.t("exhibition.camera_none"))
|
||||
return
|
||||
names = ", ".join(cam.label() for cam in cameras)
|
||||
self._log(self.t("exhibition.camera_found", count=len(cameras), names=names))
|
||||
selected = pick_default_camera(
|
||||
cameras, preferred if preferred.isdigit() else None
|
||||
)
|
||||
if selected is not None:
|
||||
self.camera_edit.setText(selected)
|
||||
|
||||
def _on_browse_camera(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
self.t("exhibition.dialog_video"),
|
||||
str(project_root()),
|
||||
"Video (*.mp4 *.avi *.mov);;All (*)",
|
||||
)
|
||||
if path:
|
||||
self.camera_edit.setText(path)
|
||||
|
||||
def _on_browse_config(self) -> None:
|
||||
start_dir = orisys_configs_dir() or project_root()
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
self.t("dialogs.select_config"),
|
||||
str(start_dir),
|
||||
"JSON (*.json);;All (*)",
|
||||
)
|
||||
if path:
|
||||
self._set_config_selection(path)
|
||||
self._update_config_tooltip()
|
||||
|
||||
def _on_start(self) -> None:
|
||||
ok, detail = orisys_importable()
|
||||
if not ok:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.t("dialogs.orisys_title"),
|
||||
self.t("dialogs.orisys_required", detail=detail),
|
||||
)
|
||||
return
|
||||
config = self._selected_config()
|
||||
if not config:
|
||||
QMessageBox.warning(
|
||||
self, self.t("dialogs.config_title"), self.t("dialogs.config_required")
|
||||
)
|
||||
return
|
||||
try:
|
||||
mode = load_fixed_mode(config)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
self.t("dialogs.config_title"),
|
||||
self.t("labels.fixed_mode_invalid", error=exc),
|
||||
)
|
||||
return
|
||||
|
||||
self.force_history.reset()
|
||||
self._awaiting_first_frame = True
|
||||
self._source_pixmap = None
|
||||
self.image_label.clear()
|
||||
self.image_label.setText(self.t("labels.no_preview"))
|
||||
self.btn_start.setEnabled(False)
|
||||
self.btn_start.setText(self.t("exhibition.btn_starting"))
|
||||
self.sensor_combo.setEnabled(False)
|
||||
|
||||
try:
|
||||
if self._sensor_type == "dd06":
|
||||
self.acquisition = self.dd06_worker
|
||||
mode = self.dd06_worker.start(
|
||||
self._parse_camera_source(),
|
||||
config_path=config,
|
||||
)
|
||||
else:
|
||||
self.acquisition = self.stitch_worker
|
||||
mode = self.stitch_worker.start(
|
||||
self._parse_camera_source(),
|
||||
config_path=config,
|
||||
cal_path=None,
|
||||
backend="auto",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._awaiting_first_frame = False
|
||||
self.btn_start.setEnabled(True)
|
||||
self.btn_start.setText(self.t("exhibition.btn_start"))
|
||||
self.sensor_combo.setEnabled(True)
|
||||
QMessageBox.critical(self, self.t("dialogs.capture_title"), str(exc))
|
||||
self._log(self.t("status.capture_error", message=exc))
|
||||
return
|
||||
|
||||
self._fixed_mode = mode
|
||||
self.btn_start.setText(self.t("exhibition.btn_start"))
|
||||
self.btn_stop.setEnabled(True)
|
||||
self.btn_reset.setEnabled(True)
|
||||
self.btn_record.setEnabled(False)
|
||||
self.btn_search.setEnabled(False)
|
||||
self.btn_camera.setEnabled(False)
|
||||
self.camera_edit.setEnabled(False)
|
||||
self.config_combo.setEnabled(False)
|
||||
self.status_label.setText(self.t("status.opening", mode=mode.label()))
|
||||
self._log(self.t("status.opening", mode=mode.label()))
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
if self.recorder.is_recording:
|
||||
self._stop_record()
|
||||
self.stitch_worker.stop()
|
||||
self.dd06_worker.stop()
|
||||
self._awaiting_first_frame = False
|
||||
self._last_packet = None
|
||||
self._source_pixmap = None
|
||||
self.image_label.clear()
|
||||
self.image_label.setText(self.t("labels.no_preview"))
|
||||
self.btn_start.setEnabled(orisys_importable()[0])
|
||||
self.btn_stop.setEnabled(False)
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_record.setEnabled(False)
|
||||
self.btn_search.setEnabled(True)
|
||||
self.btn_camera.setEnabled(True)
|
||||
self.camera_edit.setEnabled(True)
|
||||
self.config_combo.setEnabled(True)
|
||||
self.sensor_combo.setEnabled(True)
|
||||
self.status_label.setText(self.t("status.preview_stopped"))
|
||||
self._log(self.t("status.preview_stopped"))
|
||||
|
||||
def _on_reset(self) -> None:
|
||||
if not self._pipeline_running():
|
||||
self._log(self.t("exhibition.reset_need_start"))
|
||||
return
|
||||
try:
|
||||
self.acquisition.reset()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(self.t("exhibition.reset_failed", message=exc))
|
||||
return
|
||||
self._log(self.t("exhibition.reset_done"))
|
||||
|
||||
def _on_toggle_record(self) -> None:
|
||||
if self.recorder.is_recording:
|
||||
self._stop_record()
|
||||
else:
|
||||
self._start_record()
|
||||
|
||||
def _start_record(self) -> None:
|
||||
packet = self._last_packet or self.acquisition.latest()
|
||||
if packet is None:
|
||||
QMessageBox.warning(
|
||||
self, self.t("dialogs.recording_title"), self.t("dialogs.no_frame")
|
||||
)
|
||||
return
|
||||
record_fps = packet.fps or (packet.mode.fps if packet.mode else 30.0)
|
||||
try:
|
||||
path = self.recorder.start(
|
||||
self.recordings_dir, fps=float(record_fps), frame=packet.raw_frame
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, self.t("dialogs.recording_title"), str(exc))
|
||||
return
|
||||
self._refresh_record_button()
|
||||
self._log(self.t("exhibition.recording_started", name=path.name))
|
||||
|
||||
def _stop_record(self) -> None:
|
||||
path, count = self.recorder.stop()
|
||||
self._refresh_record_button()
|
||||
if path is None:
|
||||
return
|
||||
self._log(self.t("status.saved", count=count, path=path))
|
||||
|
||||
def _on_acquisition_error(self, message: str) -> None:
|
||||
self._awaiting_first_frame = False
|
||||
self.btn_start.setEnabled(orisys_importable()[0])
|
||||
self.btn_stop.setEnabled(False)
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_record.setEnabled(False)
|
||||
self.btn_search.setEnabled(True)
|
||||
self.btn_camera.setEnabled(True)
|
||||
self.camera_edit.setEnabled(True)
|
||||
self.config_combo.setEnabled(True)
|
||||
self.sensor_combo.setEnabled(True)
|
||||
self.status_label.setText(self.t("status.capture_error", message=message))
|
||||
self._log(self.t("status.capture_error", message=message))
|
||||
QMessageBox.warning(self, self.t("dialogs.capture_title"), message)
|
||||
|
||||
def _on_acquisition_status(self, message: str) -> None:
|
||||
self.status_label.setText(message)
|
||||
self._log(message)
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
packet = self.acquisition.latest()
|
||||
if packet is None:
|
||||
return
|
||||
packet_id = id(packet)
|
||||
if packet_id == self._last_packet_id and not self._awaiting_first_frame:
|
||||
return
|
||||
|
||||
if self._awaiting_first_frame:
|
||||
self._awaiting_first_frame = False
|
||||
self.btn_record.setEnabled(True)
|
||||
info = self.t(
|
||||
"status.frame_info",
|
||||
raw_w=packet.width,
|
||||
raw_h=packet.height,
|
||||
proc_w=packet.processed_width,
|
||||
proc_h=packet.processed_height,
|
||||
fps=packet.fps,
|
||||
)
|
||||
self.status_label.setText(info)
|
||||
self._log(info)
|
||||
self._log(
|
||||
self.t(
|
||||
"exhibition.force_fn_n",
|
||||
value=calibrate_normal_force(packet.normal_force),
|
||||
)
|
||||
)
|
||||
|
||||
self._last_packet = packet
|
||||
self._last_packet_id = packet_id
|
||||
self._update_image(packet)
|
||||
self.force_history.push(
|
||||
packet.timestamp,
|
||||
packet.normal_force,
|
||||
packet.shear_x,
|
||||
packet.shear_y,
|
||||
)
|
||||
self._update_plots()
|
||||
|
||||
if self.recorder.is_recording:
|
||||
self.recorder.write(packet.raw_frame)
|
||||
self.status_label.setText(
|
||||
self.t(
|
||||
"status.recording_frames",
|
||||
name=self.recorder.path.name if self.recorder.path else "",
|
||||
count=self.recorder.frame_count,
|
||||
)
|
||||
)
|
||||
|
||||
def _update_image(self, packet: SdkPacket) -> None:
|
||||
if self._view_mode == "raw":
|
||||
frame = packet.raw_frame
|
||||
elif self._view_mode == "captured":
|
||||
frame = draw_arrow_display(
|
||||
packet.processed_frame, packet.flow, background="captured"
|
||||
)
|
||||
else:
|
||||
frame = draw_arrow_display(
|
||||
packet.processed_frame, packet.flow, background="pure_color"
|
||||
)
|
||||
h, w = frame.shape[:2]
|
||||
aspect = float(w) / float(h) if h else 1.0
|
||||
first = self._source_pixmap is None
|
||||
aspect_changed = abs(aspect - self._display_aspect) > 1e-3
|
||||
self._display_aspect = aspect
|
||||
self._source_pixmap = ndarray_to_pixmap(frame)
|
||||
if first or aspect_changed:
|
||||
self._adjust_image_panel()
|
||||
else:
|
||||
self._render_image()
|
||||
|
||||
def _adjust_image_panel(self) -> None:
|
||||
available = max(self.height() - 220, 200)
|
||||
image_w = int(available * self._display_aspect)
|
||||
image_w = max(min(image_w, max(self.width() // 2, 240)), 240)
|
||||
self.h_splitter.setSizes([image_w, max(self.width() - image_w, 320)])
|
||||
self._render_image()
|
||||
|
||||
def _render_image(self) -> None:
|
||||
if self._source_pixmap is None or self._source_pixmap.isNull():
|
||||
return
|
||||
target = self.image_label.size()
|
||||
if target.width() < 2 or target.height() < 2:
|
||||
return
|
||||
scaled = self._source_pixmap.scaled(
|
||||
target, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
||||
)
|
||||
self.image_label.setPixmap(scaled)
|
||||
|
||||
def _update_plots(self) -> None:
|
||||
state = self.force_history.state()
|
||||
if state is None:
|
||||
return
|
||||
for i, curve in enumerate(self.curves):
|
||||
x = state.times
|
||||
y = state.values[:, i]
|
||||
curve.setData(x, y)
|
||||
ymin, ymax = state.y_ranges[i]
|
||||
self.plots[i].setYRange(ymin, ymax)
|
||||
self.dot_cores[i].setData(x[-1:], y[-1:])
|
||||
x0, x1 = state.x_range
|
||||
for plot in self.plots:
|
||||
plot.setXRange(x0, x1, padding=0)
|
||||
|
||||
def resizeEvent(self, event) -> None: # noqa: N802
|
||||
super().resizeEvent(event)
|
||||
self._render_image()
|
||||
|
||||
def keyPressEvent(self, event) -> None: # noqa: N802
|
||||
key = event.key()
|
||||
if key == Qt.Key_Escape:
|
||||
if self.isFullScreen():
|
||||
self.showNormal()
|
||||
else:
|
||||
self.showFullScreen()
|
||||
elif key == Qt.Key_Q:
|
||||
self.close()
|
||||
elif key == Qt.Key_S:
|
||||
if not self.recorder.is_recording and self._pipeline_running():
|
||||
self._start_record()
|
||||
elif key == Qt.Key_E:
|
||||
if self.recorder.is_recording:
|
||||
self._stop_record()
|
||||
elif key == Qt.Key_R:
|
||||
self._on_reset()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
if self.recorder.is_recording:
|
||||
self.recorder.stop()
|
||||
self.stitch_worker.stop()
|
||||
self.dd06_worker.stop()
|
||||
self.timer.stop()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Force calibration coefficients and plot defaults from Orisys viewer2d."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Linear FN → Newtons (y = mx + c), from orisys examples/gui/sensors/adapter.py.
|
||||
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
||||
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
||||
|
||||
# PyQtGraph force-curve tuning from examples/gui/viewer2d/backend.py.
|
||||
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||
PLOT_BUFFER_POINTS = 120
|
||||
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
|
||||
Y_AXIS_MIN_SPAN = 20000.0
|
||||
Y_RANGE_ALPHA = 0.5
|
||||
LOWPASS_CUTOFF_HZ = 10.0
|
||||
|
||||
|
||||
def calibrate_normal_force(raw_value: float) -> float:
|
||||
"""Convert raw normal-force reading to Newtons using y = mx + c."""
|
||||
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
|
||||
@@ -1,22 +1,30 @@
|
||||
"""Entry point for the Sensor Bench desktop application."""
|
||||
"""Entry point for the public Sensor Bench exhibition application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Must run before any module that imports cv2 (exhibition_app, acquisition, etc.).
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from .app import MainWindow
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv if argv is None else argv)
|
||||
qt_app = QApplication(args)
|
||||
parser_args = list(sys.argv[1:] if argv is None else argv[1:])
|
||||
# Accept unknown Qt args (e.g. -platform offscreen) without advertising
|
||||
# internal-only flags on the public CLI.
|
||||
qt_args = parser_args
|
||||
|
||||
qt_app = QApplication([sys.argv[0], *qt_args] if argv is None else [argv[0], *qt_args])
|
||||
qt_app.setApplicationName("Sensor Bench")
|
||||
qt_app.setOrganizationName("Orisys-Tech")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
from .exhibition_app import ExhibitionWindow
|
||||
|
||||
window = ExhibitionWindow()
|
||||
window.showMaximized()
|
||||
return qt_app.exec_()
|
||||
|
||||
|
||||
|
||||
+120
-20
@@ -6,6 +6,9 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NON_SENSOR_CONFIG_STEMS = {"options", "bench_theme"}
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
@@ -29,20 +32,6 @@ def project_root() -> Path:
|
||||
return app_root()
|
||||
|
||||
|
||||
def default_options_path() -> Path:
|
||||
external = app_root() / "config" / "options.json"
|
||||
if external.exists():
|
||||
return external
|
||||
return resource_root() / "config" / "options.json"
|
||||
|
||||
|
||||
def default_steps_path() -> Path:
|
||||
external = app_root() / "steps" / "default.json"
|
||||
if external.exists():
|
||||
return external
|
||||
return resource_root() / "steps" / "default.json"
|
||||
|
||||
|
||||
def default_locales_dir() -> Path:
|
||||
external = app_root() / "config" / "locales"
|
||||
if external.exists():
|
||||
@@ -64,17 +53,128 @@ def default_orisys_sdk_root() -> Path:
|
||||
return Path(r"D:\repo\orisys-sdk")
|
||||
|
||||
|
||||
def default_ddjx01_config() -> str:
|
||||
if is_frozen():
|
||||
return "ddjx01"
|
||||
path = default_orisys_sdk_root() / "config" / "ddjx01.json"
|
||||
if path.exists():
|
||||
return str(path)
|
||||
def local_sensor_configs_dir() -> Path:
|
||||
"""Editable sensor configs next to the app (or repo) root."""
|
||||
return app_root() / "config"
|
||||
|
||||
|
||||
def _is_sensor_config_json(path: Path) -> bool:
|
||||
if path.suffix.lower() != ".json":
|
||||
return False
|
||||
if path.parent.name.lower() == "locales":
|
||||
return False
|
||||
if path.stem.lower() in NON_SENSOR_CONFIG_STEMS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_local_sensor_config_files() -> list[Path]:
|
||||
"""Return local editable sensor config JSON paths (excludes options/locales)."""
|
||||
root = local_sensor_configs_dir()
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(p for p in root.glob("*.json") if _is_sensor_config_json(p))
|
||||
|
||||
|
||||
def orisys_configs_dir() -> Path | None:
|
||||
"""Prefer local sensor configs dir when present; else installed package dir."""
|
||||
local = local_sensor_configs_dir()
|
||||
if list_local_sensor_config_files():
|
||||
return local
|
||||
try:
|
||||
import orisys
|
||||
|
||||
path = Path(orisys.configs.CONFIG_DIR)
|
||||
return path if path.exists() else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def list_orisys_configs() -> list[str]:
|
||||
"""Return selectable sensor configs.
|
||||
|
||||
Local ``config/*.json`` files (excluding non-sensor stems such as
|
||||
``bench_theme``) are listed first as
|
||||
absolute paths so Start uses the editable copy beside the EXE. Package stems
|
||||
from the installed Orisys wheel are appended when not already covered.
|
||||
"""
|
||||
entries: list[str] = []
|
||||
seen_stems: set[str] = set()
|
||||
|
||||
for path in list_local_sensor_config_files():
|
||||
stem = path.stem.lower()
|
||||
if stem in seen_stems:
|
||||
continue
|
||||
entries.append(str(path.resolve()))
|
||||
seen_stems.add(stem)
|
||||
|
||||
try:
|
||||
import orisys
|
||||
|
||||
for stem in orisys.configs.list_config():
|
||||
key = str(stem).strip()
|
||||
if not key:
|
||||
continue
|
||||
if key.lower() in NON_SENSOR_CONFIG_STEMS:
|
||||
continue
|
||||
if key.lower() in seen_stems:
|
||||
continue
|
||||
entries.append(key)
|
||||
seen_stems.add(key.lower())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def config_display_name(value: str) -> str:
|
||||
"""Short label for a config path or package stem."""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
path = Path(text)
|
||||
if path.suffix.lower() == ".json" or "/" in text or "\\" in text:
|
||||
return path.stem
|
||||
return text
|
||||
|
||||
|
||||
def default_config_name(sensor_type: str = "orisys") -> str:
|
||||
"""Preferred package config name for a sensor pipeline."""
|
||||
kind = str(sensor_type or "orisys").strip().lower()
|
||||
if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"):
|
||||
return "dd06-ov"
|
||||
return "ddjx01"
|
||||
|
||||
|
||||
def _pick_default_config(preferred_stem: str) -> str:
|
||||
entries = list_orisys_configs()
|
||||
for entry in entries:
|
||||
if config_display_name(entry).lower() == preferred_stem.lower():
|
||||
return entry
|
||||
if entries:
|
||||
return entries[0]
|
||||
return preferred_stem
|
||||
|
||||
|
||||
def default_ddjx01_config() -> str:
|
||||
"""Prefer local editable config path, else package stem."""
|
||||
return _pick_default_config(default_config_name("orisys"))
|
||||
|
||||
|
||||
def default_ddjx01_calibration() -> Path:
|
||||
external = app_root() / "config" / "ddjx01.npy"
|
||||
if external.exists():
|
||||
return external
|
||||
return default_orisys_sdk_root() / "config" / "ddjx01.npy"
|
||||
|
||||
|
||||
def default_dd06_config() -> str:
|
||||
preferred = default_config_name("dd06")
|
||||
entries = list_orisys_configs()
|
||||
for stem in (preferred, "dd06"):
|
||||
for entry in entries:
|
||||
if config_display_name(entry).lower() == stem.lower():
|
||||
return entry
|
||||
if entries:
|
||||
return preferred
|
||||
return preferred
|
||||
|
||||
@@ -16,19 +16,70 @@ class PlotState:
|
||||
y_ranges: tuple[tuple[float, float], ...]
|
||||
|
||||
|
||||
class MetricHistory:
|
||||
"""Ring-style buffer for FPS / brightness (or other scalar metrics)."""
|
||||
def lowpass_filter(
|
||||
data,
|
||||
cutoff_hz: float,
|
||||
sample_rate_hz: float,
|
||||
order: int = 2,
|
||||
) -> np.ndarray:
|
||||
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
|
||||
y = np.asarray(data, dtype=np.float64)
|
||||
if cutoff_hz == 0:
|
||||
return y.copy()
|
||||
if y.size == 0:
|
||||
return y
|
||||
|
||||
def __init__(self, npoints: int = 120, *, channels: int = 2, window_sec: float = 4.0):
|
||||
nyquist = 0.5 * sample_rate_hz
|
||||
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
||||
raise ValueError(
|
||||
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
|
||||
)
|
||||
|
||||
from scipy.signal import butter, filtfilt
|
||||
|
||||
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
||||
padlen = 3 * (max(len(b), len(a)) - 1)
|
||||
if y.size <= padlen:
|
||||
return y.copy()
|
||||
return filtfilt(b, a, y)
|
||||
|
||||
|
||||
class MetricHistory:
|
||||
"""Ring-style buffer for force / metric curves (viewer2d-compatible options)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
npoints: int = 120,
|
||||
*,
|
||||
channels: int = 2,
|
||||
window_sec: float = 4.0,
|
||||
sample_rate_hz: float | None = None,
|
||||
lowpass_cutoff_hz: float = 0.0,
|
||||
y_axis_min_span: float | None = None,
|
||||
y_range_alpha: float = 0.5,
|
||||
):
|
||||
self.npoints = int(npoints)
|
||||
self.channels = int(channels)
|
||||
self.window_sec = float(window_sec)
|
||||
self.sample_rate_hz = (
|
||||
float(sample_rate_hz)
|
||||
if sample_rate_hz is not None
|
||||
else (self.npoints / self.window_sec if self.window_sec > 0 else 30.0)
|
||||
)
|
||||
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
||||
self.y_axis_min_span = (
|
||||
None if y_axis_min_span is None else float(y_axis_min_span)
|
||||
)
|
||||
self.y_range_alpha = float(y_range_alpha)
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._data = np.full((self.npoints, 1 + self.channels), np.nan, dtype=np.float64)
|
||||
self.sample_count = 0
|
||||
self._t0: Optional[float] = None
|
||||
self._y_range_smooth: list[Optional[tuple[float, float]]] = [
|
||||
None for _ in range(self.channels)
|
||||
]
|
||||
|
||||
def push(self, timestamp: float, *values: float) -> None:
|
||||
if len(values) != self.channels:
|
||||
@@ -44,6 +95,37 @@ class MetricHistory:
|
||||
self._data[:-1, :] = self._data[1:, :]
|
||||
self._data[-1, :] = row
|
||||
|
||||
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
|
||||
if self.y_axis_min_span is None:
|
||||
if abs(ymax - ymin) < 1e-6:
|
||||
pad = 1.0 if abs(ymin) < 1e-6 else abs(ymin) * 0.1
|
||||
ymin -= pad
|
||||
ymax += pad
|
||||
else:
|
||||
pad = (ymax - ymin) * 0.12
|
||||
ymin -= pad
|
||||
ymax += pad
|
||||
return ymin, ymax
|
||||
|
||||
if self._y_range_smooth[channel] is None:
|
||||
smoothed = (ymin, ymax)
|
||||
else:
|
||||
prev_min, prev_max = self._y_range_smooth[channel]
|
||||
alpha = self.y_range_alpha
|
||||
ymin = alpha * ymin + (1.0 - alpha) * prev_min
|
||||
ymax = alpha * ymax + (1.0 - alpha) * prev_max
|
||||
smoothed = (ymin, ymax)
|
||||
|
||||
span = smoothed[1] - smoothed[0]
|
||||
if span < self.y_axis_min_span:
|
||||
mid = 0.5 * (smoothed[0] + smoothed[1])
|
||||
half = self.y_axis_min_span / 2.0
|
||||
smoothed = (mid - half, mid + half)
|
||||
|
||||
self._y_range_smooth[channel] = smoothed
|
||||
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
|
||||
return smoothed[0] - pad, smoothed[1] + pad
|
||||
|
||||
def state(self) -> Optional[PlotState]:
|
||||
n = self.sample_count
|
||||
if n == 0:
|
||||
@@ -51,6 +133,14 @@ class MetricHistory:
|
||||
block = self._data[:n]
|
||||
times = block[:, 0].copy()
|
||||
values = block[:, 1:].copy()
|
||||
if self.lowpass_cutoff_hz > 0:
|
||||
for i in range(self.channels):
|
||||
values[:, i] = lowpass_filter(
|
||||
values[:, i],
|
||||
self.lowpass_cutoff_hz,
|
||||
sample_rate_hz=self.sample_rate_hz,
|
||||
)
|
||||
|
||||
if n < self.npoints:
|
||||
x_range = (0.0, self.window_sec)
|
||||
else:
|
||||
@@ -67,15 +157,7 @@ class MetricHistory:
|
||||
ymax = float(np.nanmax(col))
|
||||
if not np.isfinite(ymin) or not np.isfinite(ymax):
|
||||
ymin, ymax = 0.0, 1.0
|
||||
if abs(ymax - ymin) < 1e-6:
|
||||
pad = 1.0 if abs(ymin) < 1e-6 else abs(ymin) * 0.1
|
||||
ymin -= pad
|
||||
ymax += pad
|
||||
else:
|
||||
pad = (ymax - ymin) * 0.12
|
||||
ymin -= pad
|
||||
ymax += pad
|
||||
y_ranges.append((ymin, ymax))
|
||||
y_ranges.append(self._smooth_y_range(i, ymin, ymax))
|
||||
|
||||
return PlotState(
|
||||
times=times,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Polygon ROI geometry helpers for DD06-style rectification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_json_config(path: str | Path) -> dict[str, Any]:
|
||||
path = Path(path)
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
try:
|
||||
import orisys
|
||||
|
||||
return orisys.configs.load_config(str(path))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FileNotFoundError(f"Config not found as file or bundled name: {path}") from exc
|
||||
|
||||
|
||||
def load_roi_polygon(config: dict[str, Any]):
|
||||
"""Return (points Nx2 float32, image_size [w,h]) from nested or legacy ROI fields."""
|
||||
roi = config.get("roi") if isinstance(config.get("roi"), dict) else config
|
||||
if not isinstance(roi, dict):
|
||||
raise ValueError("Config has no usable ROI section")
|
||||
|
||||
image_size = roi.get("image_size") or config.get("camera", {}).get("resolution_raw")
|
||||
if image_size is None:
|
||||
raise ValueError("ROI image_size is missing")
|
||||
|
||||
points = roi.get("points") or roi.get("polygon") or roi.get("roi_polygon")
|
||||
if points is None and roi.get("xyxy") is not None:
|
||||
x1, y1, x2, y2 = [int(v) for v in roi["xyxy"]]
|
||||
points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
|
||||
if not points:
|
||||
raise ValueError("ROI points are missing")
|
||||
|
||||
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
|
||||
if pts.shape[0] < 4:
|
||||
raise ValueError(f"ROI needs at least 4 points, got {pts.shape[0]}")
|
||||
return pts, [int(image_size[0]), int(image_size[1])]
|
||||
|
||||
|
||||
def scale_points(points, source_size, target_size):
|
||||
source_w, source_h = float(source_size[0]), float(source_size[1])
|
||||
target_w, target_h = float(target_size[0]), float(target_size[1])
|
||||
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy()
|
||||
if source_w > 0 and source_h > 0 and (source_w, source_h) != (target_w, target_h):
|
||||
pts[:, 0] *= target_w / source_w
|
||||
pts[:, 1] *= target_h / source_h
|
||||
return pts
|
||||
|
||||
|
||||
def order_quad_points(points):
|
||||
"""Order 4 points as TL, TR, BR, BL for perspective transforms."""
|
||||
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
|
||||
if pts.shape[0] != 4:
|
||||
hull = cv2.convexHull(pts.astype(np.float32))
|
||||
rect = cv2.minAreaRect(hull)
|
||||
pts = cv2.boxPoints(rect).astype(np.float32)
|
||||
|
||||
s = pts.sum(axis=1)
|
||||
diff = np.diff(pts, axis=1).reshape(-1)
|
||||
ordered = np.zeros((4, 2), dtype=np.float32)
|
||||
ordered[0] = pts[np.argmin(s)]
|
||||
ordered[2] = pts[np.argmax(s)]
|
||||
ordered[1] = pts[np.argmin(diff)]
|
||||
ordered[3] = pts[np.argmax(diff)]
|
||||
return ordered
|
||||
|
||||
|
||||
def rectified_output_size(config, points=None):
|
||||
"""Prefer camera.resolution_stitch; fall back to ROI bounding box size."""
|
||||
stitch = config.get("camera", {}).get("resolution_stitch")
|
||||
if stitch is not None and len(stitch) >= 2:
|
||||
return int(stitch[0]), int(stitch[1])
|
||||
if points is not None:
|
||||
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
|
||||
width = max(
|
||||
int(np.linalg.norm(pts[1] - pts[0])),
|
||||
int(np.linalg.norm(pts[2] - pts[3])),
|
||||
1,
|
||||
)
|
||||
height = max(
|
||||
int(np.linalg.norm(pts[3] - pts[0])),
|
||||
int(np.linalg.norm(pts[2] - pts[1])),
|
||||
1,
|
||||
)
|
||||
return width, height
|
||||
raise ValueError("Cannot determine rectified output size")
|
||||
|
||||
|
||||
def build_roi_warp(points, source_image_size, frame_size, output_size):
|
||||
frame_w, frame_h = int(frame_size[0]), int(frame_size[1])
|
||||
scaled = scale_points(points, source_image_size, (frame_w, frame_h))
|
||||
src = order_quad_points(scaled)
|
||||
out_w, out_h = int(output_size[0]), int(output_size[1])
|
||||
dst = np.array(
|
||||
[
|
||||
[0.0, 0.0],
|
||||
[out_w - 1.0, 0.0],
|
||||
[out_w - 1.0, out_h - 1.0],
|
||||
[0.0, out_h - 1.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
matrix = cv2.getPerspectiveTransform(src, dst)
|
||||
return matrix, src, (out_w, out_h)
|
||||
|
||||
|
||||
def warp_roi_to_rectangle(frame, matrix, output_size):
|
||||
out_w, out_h = int(output_size[0]), int(output_size[1])
|
||||
return cv2.warpPerspective(
|
||||
frame,
|
||||
matrix,
|
||||
(out_w, out_h),
|
||||
flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_REPLICATE,
|
||||
)
|
||||
|
||||
|
||||
def open_video_source(video):
|
||||
"""Open a camera index or video file. Returns (cap, resolved_source)."""
|
||||
text = str(video).strip()
|
||||
if text.isdigit():
|
||||
source = int(text)
|
||||
cap = cv2.VideoCapture(source, cv2.CAP_DSHOW)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
cap = cv2.VideoCapture(source)
|
||||
else:
|
||||
path = Path(text)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Video not found: {path}")
|
||||
source = str(path)
|
||||
cap = cv2.VideoCapture(source)
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"Failed to open video source: {video}")
|
||||
return cap, source
|
||||
Reference in New Issue
Block a user