initial commit, functinal software

This commit is contained in:
ykcheungab
2026-09-06 21:57:50 +08:00
commit e2d5e69229
78 changed files with 5407 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-app-shared"
version = "0.1.0"
description = "Shared paths, prefs, theme, i18n, and logging for orisys-app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
"PyQt5>=5.15",
"opencv-python-headless>=4.10.0.84,<5",
"numpy>=1.24.4",
"scipy>=1.10.1",
"pyqtgraph>=0.13",
"orisys>=0.5",
]
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
orisys_app_shared = ["theme/*.json"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+4
View File
@@ -0,0 +1,4 @@
"""Shared package for orisys-app desktop products."""
__version__ = "0.1.0"
API_VERSION = "0.1"
@@ -0,0 +1,45 @@
"""Acquisition helpers."""
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
__all__ = [
"FORCE_SAMPLE_RATE_HZ",
"LOWPASS_CUTOFF_HZ",
"FingerAcquisitionWorker",
"SdkAcquisitionWorker",
"SdkPacket",
"depth_map_to_bgr",
"draw_arrow_display",
"hex_to_bgr",
"load_fixed_mode",
"orisys_importable",
"resolve_finger_export_dir",
]
_LAZY_ATTRS = {
"FingerAcquisitionWorker": ("orisys_app_shared.acquisition.finger_worker", "FingerAcquisitionWorker"),
"resolve_finger_export_dir": ("orisys_app_shared.acquisition.finger_worker", "resolve_finger_export_dir"),
"SdkAcquisitionWorker": ("orisys_app_shared.acquisition.sdk_worker", "SdkAcquisitionWorker"),
"SdkPacket": ("orisys_app_shared.acquisition.sdk_worker", "SdkPacket"),
"load_fixed_mode": ("orisys_app_shared.acquisition.sdk_worker", "load_fixed_mode"),
"orisys_importable": ("orisys_app_shared.acquisition.sdk_worker", "orisys_importable"),
"depth_map_to_bgr": ("orisys_app_shared.acquisition.arrows", "depth_map_to_bgr"),
"draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"),
"hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"),
}
def __getattr__(name: str):
target = _LAZY_ATTRS.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr = target
import importlib
module = importlib.import_module(module_name)
value = getattr(module, attr)
globals()[name] = value
return value
@@ -0,0 +1,93 @@
"""Arrow overlay helpers for processed tactile frames."""
from __future__ import annotations
from typing import Optional, Sequence
import cv2
import numpy as np
ARROW_BG_BGR = (0x16, 0x13, 0x12)
_ARROW_DRAW_KW = dict(
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
_FINGER_ARROW_DRAW_KW = dict(
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
below_threshold_color=(160, 160, 160),
)
def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> tuple[int, int, int]:
text = str(hex_color or "").strip().lstrip("#")
if len(text) != 6:
return fallback
try:
r = int(text[0:2], 16)
g = int(text[2:4], 16)
b = int(text[4:6], 16)
except ValueError:
return fallback
return (b, g, r)
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
def draw_arrow_display(
processed_frame: np.ndarray,
flow: Optional[np.ndarray],
*,
background: str = "pure_color",
canvas_bgr: Sequence[int] | None = None,
draw_style: str = "box",
valid_mask: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Draw flow arrows on a solid canvas or over the processed frame."""
base = _ensure_bgr(processed_frame)
if flow is None:
return base
draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW
try:
import orisys
if background == "pure_color":
canvas = np.empty_like(base)
color = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
canvas[:, :] = color
return orisys.util.draw_arrows(
canvas, flow, valid_mask=valid_mask, **draw_kw
)
return orisys.util.draw_arrows(base, flow, valid_mask=valid_mask, **draw_kw)
except Exception:
return base
def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
"""Convert a float depth/divergence map to a JET colormap BGR image."""
if depth is None:
return None
arr = np.asarray(depth, dtype=np.float64)
if arr.size == 0 or arr.ndim < 2:
return None
finite = np.isfinite(arr)
if not np.any(finite):
return None
lo = float(np.nanmin(arr[finite]))
hi = float(np.nanmax(arr[finite]))
if hi > lo:
normalized = ((arr - lo) / (hi - lo) * 255.0).astype(np.uint8)
else:
normalized = np.zeros(arr.shape, dtype=np.uint8)
return cv2.applyColorMap(normalized, cv2.COLORMAP_JET)
@@ -0,0 +1,4 @@
"""Shared acquisition constants (no heavy imports)."""
LOWPASS_CUTOFF_HZ = 10.0
FORCE_SAMPLE_RATE_HZ = 30.0
@@ -0,0 +1,296 @@
"""Orisys finger acquisition worker (FingerRuntime → SdkPacket)."""
from __future__ import annotations
import threading
import time
from pathlib import Path
from typing import Callable, Optional
import numpy as np
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
from orisys_app_shared.acquisition.sdk_worker import (
CAMERA_DISCONNECT_TIMEOUT_S,
VIDEO_EOF_TIMEOUT_S,
FixedCameraMode,
SdkPacket,
_ensure_bgr,
_is_file_source,
load_fixed_mode,
orisys_importable,
read_forces_from_sensor,
)
_EXPORT_REQUIRED = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
"""Return local ``assets/30fps-0703`` when present and complete.
Missing folder → ``None`` (SDK default export). Incomplete folder → error
so Run does not silently fall back to bundled assets.
"""
path = Path(str(config_ref))
if path.is_file() and path.name.lower() == "sensor.json":
candidate = path.parent / "assets" / "30fps-0703"
elif path.is_dir():
candidate = path / "assets" / "30fps-0703"
else:
return None
if not candidate.is_dir():
return None
missing = [name for name in _EXPORT_REQUIRED if not (candidate / name).is_file()]
if missing:
raise FileNotFoundError(
f"Incomplete finger export at {candidate}: missing {', '.join(missing)}"
)
return candidate.resolve()
class FingerAcquisitionWorker:
"""Background loop: FingerRuntime grab/unroll + deformation + force readout."""
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._runtime = None
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._export_dir: Optional[Path] = None
self._end_reason: Optional[str] = None
self._phase: str = "idle"
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def phase(self) -> str:
with self._lock:
return self._phase
def latest(self) -> Optional[SdkPacket]:
with self._lock:
return self._latest
def consume_end_reason(self) -> Optional[str]:
with self._lock:
reason = self._end_reason
self._end_reason = None
return reason
def start(
self,
video_source: str | int,
*,
config_ref: str | Path,
) -> FixedCameraMode:
self.stop()
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(f"orisys SDK is not importable: {detail}")
mode = load_fixed_mode(config_ref)
self._mode = mode
self._video_source = video_source
self._export_dir = resolve_finger_export_dir(config_ref)
with self._lock:
self._latest = None
self._end_reason = None
self._phase = "connecting"
self._session_id += 1
session = self._session_id
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name=f"FingerAcquisition-{session}",
daemon=True,
args=(session,),
)
self._thread.start()
return mode
def stop(self) -> None:
self._set_end_reason("stopped")
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=8.0)
self._thread = None
self._close_runtime()
with self._lock:
self._latest = None
self._phase = "idle"
def reset(self) -> None:
with self._lock:
runtime = self._runtime
if runtime is None:
raise RuntimeError("Finger runtime is not running")
runtime.tactile.reset()
def _set_end_reason(self, reason: str) -> None:
with self._lock:
if self._end_reason is None:
self._end_reason = reason
def _set_phase(self, phase: str) -> None:
with self._lock:
self._phase = phase
def _close_runtime(self) -> None:
runtime = self._runtime
self._runtime = None
if runtime is None:
return
try:
runtime.close()
except Exception:
pass
def _status(self, message: str) -> None:
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int) -> None:
from orisys.finger import FingerRuntime
mode = self._mode
source = self._video_source
if mode is None or source is None:
return
is_file = _is_file_source(source)
try:
self._status(f"Opening FingerRuntime ({mode.label()})…")
open_kw: dict = {
"config": mode.config_ref,
"video": source,
"verbose": False,
}
if self._export_dir is not None:
open_kw["export_dir"] = str(self._export_dir)
self._runtime = FingerRuntime.open(**open_kw)
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
return
self._runtime.warmup(color=False)
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
return
raw_sensor = getattr(self._runtime.tactile, "raw_sensor", None)
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
try:
raw_sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
except Exception:
pass
self._status(f"Streaming finger {mode.label()}")
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
self._set_phase("idle")
return
frame_times: list[float] = []
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
runtime = self._runtime
while not self._stop.is_set() and session == self._session_id and runtime is not None:
try:
now = time.perf_counter()
if saw_frame:
if is_file:
if none_streak_start is not None and (
now - none_streak_start
) >= VIDEO_EOF_TIMEOUT_S:
self._set_end_reason("ended")
break
else:
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
self._set_end_reason("disconnected")
break
t0 = time.perf_counter()
ok, source_frame = runtime.read_source()
if not ok or source_frame is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
unrolled = runtime.process_frame(source_frame, color=False)
if unrolled is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
none_streak_start = None
tactile = runtime.tactile
try:
tactile.compute_deformation(decompose=True, check_motion=False)
tactile.compute_contact()
except Exception:
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
forces = read_forces_from_sensor(
tactile,
config_ref=mode.config_ref,
)
packet = SdkPacket(
raw_frame=_ensure_bgr(source_frame),
processed_frame=_ensure_bgr(unrolled),
timestamp=time.time(),
fps=fps,
process_ms=(t1 - t0) * 1000.0,
normal_force=forces["normal_force"],
shear_x=forces["shear_x"],
shear_y=forces["shear_y"],
normal_force_raw=forces["normal_force_raw"],
shear_x_raw=forces["shear_x_raw"],
shear_y_raw=forces["shear_y_raw"],
force_unit=forces["force_unit"],
force_calibrated=forces["force_calibrated"],
session_id=session,
flow=forces["flow"],
depth=forces.get("depth"),
valid_mask=runtime.valid_mask,
)
with self._lock:
self._latest = packet
self._phase = "running"
saw_frame = True
last_frame_mono = t1
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
break
self._close_runtime()
self._set_phase("idle")
if self._stop.is_set():
self._set_end_reason("stopped")
self._status("Finger acquisition stopped")
@@ -0,0 +1,430 @@
"""Orisys SDK acquisition worker for box/mini-style sensors."""
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import cv2
import numpy as np
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
VIDEO_EOF_TIMEOUT_S = 0.5
CAMERA_DISCONNECT_TIMEOUT_S = 1.5
@dataclass(frozen=True)
class FixedCameraMode:
width: int
height: int
fps: float
config_ref: str
def label(self) -> str:
return f"{self.width}x{self.height} @{self.fps:g}"
@dataclass
class SdkPacket:
raw_frame: np.ndarray
processed_frame: np.ndarray
timestamp: float
fps: float
process_ms: float
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
normal_force_raw: float = 0.0
shear_x_raw: float = 0.0
shear_y_raw: float = 0.0
force_unit: str = "raw"
force_calibrated: bool = False
session_id: int = 0
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
def orisys_importable() -> tuple[bool, str]:
try:
import orisys # noqa: F401
return True, getattr(__import__("orisys"), "__version__", "unknown")
except Exception as exc: # noqa: BLE001
return False, str(exc)
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
ref = str(config_ref).strip()
path = Path(ref)
if path.is_file():
data = json.loads(path.read_text(encoding="utf-8"))
resolved = str(path.resolve())
else:
import orisys
data = orisys.configs.load_config(ref)
resolved = ref
camera = data.get("camera") or {}
raw = camera.get("resolution_raw") or [1920, 1080]
fps = float(camera.get("fps") or 30.0)
return FixedCameraMode(
width=int(raw[0]),
height=int(raw[1]),
fps=fps,
config_ref=resolved,
)
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> bool:
if getattr(sensor, "_force_cal_sensor_name", None) is not None:
return True
if config_ref is None:
return False
try:
import orisys
path = Path(str(config_ref))
name = path.parent.name if path.name == "sensor.json" else path.stem
return orisys.configs.load_force_calibration(name) is not None
except Exception:
return False
def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
info = sensor.info
fn = fx = fy = 0.0
fn_raw = fx_raw = fy_raw = 0.0
flow = None
depth = None
read_raw = all(
hasattr(info, name)
for name in ("FNORMAL_RAW", "FSHEARX_RAW", "FSHEARY_RAW")
)
has_flow = hasattr(info, "VRAW")
has_depth = hasattr(info, "DEPTH")
try:
if read_raw and has_flow and has_depth:
fn, fx, fy, fn_raw, fx_raw, fy_raw, flow, depth = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
info.VRAW,
info.DEPTH,
)
elif read_raw and has_flow:
fn, fx, fy, fn_raw, fx_raw, fy_raw, flow = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
info.VRAW,
)
elif read_raw and has_depth:
fn, fx, fy, fn_raw, fx_raw, fy_raw, depth = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
info.DEPTH,
)
elif read_raw:
fn, fx, fy, fn_raw, fx_raw, fy_raw = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
)
elif has_flow and has_depth:
fn, fx, fy, flow, depth = sensor.read_info(
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.VRAW, info.DEPTH
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
elif has_flow:
fn, fx, fy, flow = sensor.read_info(
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.VRAW
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
elif has_depth:
fn, fx, fy, depth = sensor.read_info(
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.DEPTH
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
else:
fn, fx, fy = sensor.read_info(info.FNORMAL, info.FSHEARX, info.FSHEARY)
fn_raw, fx_raw, fy_raw = fn, fx, fy
except Exception:
pass
if depth is not None:
arr = np.asarray(depth)
if arr.size == 0:
depth = None
calibrated = _sensor_force_calibrated(sensor, config_ref=config_ref)
return {
"normal_force": float(fn),
"shear_x": float(fx),
"shear_y": float(fy),
"normal_force_raw": float(fn_raw),
"shear_x_raw": float(fx_raw),
"shear_y_raw": float(fy_raw),
"force_calibrated": calibrated,
"force_unit": "N" if calibrated else "raw",
"flow": flow,
"depth": depth,
}
def _is_file_source(video_source: str | int) -> bool:
return isinstance(video_source, str)
class SdkAcquisitionWorker:
"""Background loop: Sensor.get_img + deformation + force readout."""
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._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._end_reason: Optional[str] = None
self._phase: str = "idle"
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def phase(self) -> str:
with self._lock:
return self._phase
def latest(self) -> Optional[SdkPacket]:
with self._lock:
return self._latest
def consume_end_reason(self) -> Optional[str]:
with self._lock:
reason = self._end_reason
self._end_reason = None
return reason
def start(
self,
video_source: str | int,
*,
config_ref: str | Path,
) -> FixedCameraMode:
self.stop()
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(f"orisys SDK is not importable: {detail}")
mode = load_fixed_mode(config_ref)
self._mode = mode
self._video_source = video_source
with self._lock:
self._latest = None
self._end_reason = None
self._phase = "connecting"
self._session_id += 1
session = self._session_id
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name=f"SdkAcquisition-{session}",
daemon=True,
args=(session,),
)
self._thread.start()
return mode
def stop(self) -> None:
self._set_end_reason("stopped")
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_sensor()
with self._lock:
self._latest = None
self._phase = "idle"
def reset(self) -> None:
with self._lock:
sensor = self._sensor
if sensor is None:
raise RuntimeError("Sensor is not running")
sensor.reset()
def _set_end_reason(self, reason: str) -> None:
with self._lock:
if self._end_reason is None:
self._end_reason = reason
def _set_phase(self, phase: str) -> None:
with self._lock:
self._phase = phase
def _close_sensor(self) -> None:
sensor = self._sensor
self._sensor = None
if sensor is None:
return
try:
sensor.disconnect()
except Exception:
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
if mode is None or source is None:
return
is_file = _is_file_source(source)
try:
self._status(f"Opening Orisys Sensor ({mode.label()})…")
self._sensor = orisys.Sensor(
source,
config_name=mode.config_ref,
verbose=False,
backend="auto",
)
if self._stop.is_set() or session != self._session_id:
self._close_sensor()
return
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:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
self._set_phase("idle")
return
frame_times: list[float] = []
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
while not self._stop.is_set() and session == self._session_id:
try:
now = time.perf_counter()
if saw_frame:
if is_file:
if none_streak_start is not None and (
now - none_streak_start
) >= VIDEO_EOF_TIMEOUT_S:
self._set_end_reason("ended")
break
else:
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
self._set_end_reason("disconnected")
break
t0 = time.perf_counter()
processed = self._sensor.get_img()
if processed is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
raw = getattr(self._sensor, "frame", None)
if raw is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
none_streak_start = None
try:
self._sensor.compute_deformation(decompose=True, check_motion=False)
self._sensor.compute_contact()
except Exception:
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
forces = read_forces_from_sensor(self._sensor, config_ref=mode.config_ref)
packet = SdkPacket(
raw_frame=_ensure_bgr(raw),
processed_frame=_ensure_bgr(processed),
timestamp=time.time(),
fps=fps,
process_ms=(t1 - t0) * 1000.0,
normal_force=forces["normal_force"],
shear_x=forces["shear_x"],
shear_y=forces["shear_y"],
normal_force_raw=forces["normal_force_raw"],
shear_x_raw=forces["shear_x_raw"],
shear_y_raw=forces["shear_y_raw"],
force_unit=forces["force_unit"],
force_calibrated=forces["force_calibrated"],
session_id=session,
flow=forces["flow"],
depth=forces.get("depth"),
)
with self._lock:
self._latest = packet
self._phase = "running"
saw_frame = True
last_frame_mono = t1
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
break
self._close_sensor()
self._set_phase("idle")
if self._stop.is_set():
self._set_end_reason("stopped")
self._status("Acquisition stopped")
@@ -0,0 +1,11 @@
"""Camera discovery and raw preview helpers."""
from orisys_app_shared.camera.discovery import CameraInfo, list_cameras, pick_default_camera
from orisys_app_shared.camera.preview import RawPreviewSession
__all__ = [
"CameraInfo",
"RawPreviewSession",
"list_cameras",
"pick_default_camera",
]
@@ -0,0 +1,138 @@
"""USB camera discovery for orisys-app (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:
cv_logging = getattr(getattr(cv2, "utils", None), "logging", None)
if cv_logging is not None:
set_level = getattr(cv_logging, "setLogLevel", None)
get_level = getattr(cv_logging, "getLogLevel", None)
silent = getattr(cv_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,72 @@
"""Raw OpenCV camera preview session."""
from __future__ import annotations
import os
from typing import Any
# Must be set before importing cv2 on Windows MSMF builds.
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import cv2
import numpy as np
from orisys_app_shared.camera.discovery import _backend_candidates
class RawPreviewSession:
"""Open a camera index and read BGR frames for UI preview."""
def __init__(self) -> None:
self._cap: Any = None
self._index: int | None = None
self._backend_name: str = ""
@property
def is_open(self) -> bool:
return self._cap is not None and bool(self._cap.isOpened())
@property
def index(self) -> int | None:
return self._index
@property
def backend_name(self) -> str:
return self._backend_name
def open(self, index: int) -> None:
self.close()
last_error = f"Camera {index} failed to open"
for backend_name, backend_api in _backend_candidates():
cap = cv2.VideoCapture(int(index), backend_api)
if not cap.isOpened():
cap.release()
continue
ok, frame = cap.read()
if not ok or frame is None:
cap.release()
last_error = f"Camera {index} opened via {backend_name} but returned no frame"
continue
self._cap = cap
self._index = int(index)
self._backend_name = backend_name
return
raise RuntimeError(last_error)
def read(self) -> np.ndarray | None:
if not self.is_open:
return None
ok, frame = self._cap.read()
if not ok or frame is None:
return None
return frame
def close(self) -> None:
if self._cap is not None:
try:
self._cap.release()
except Exception:
pass
self._cap = None
self._index = None
self._backend_name = ""
+24
View File
@@ -0,0 +1,24 @@
"""Shared API compatibility helpers."""
from __future__ import annotations
import logging
from orisys_app_shared import API_VERSION, __version__ as shared_version
logger = logging.getLogger(__name__)
def check_shared_compat(required_api: str) -> bool:
"""Return True if shared API matches the app requirement; log a warning otherwise."""
required = str(required_api or "").strip()
current = str(API_VERSION).strip()
ok = required == current
if not ok:
logger.warning(
"Shared API mismatch: app requires %s, shared provides %s (shared package %s)",
required,
current,
shared_version,
)
return ok
@@ -0,0 +1,26 @@
"""Force history and unit helpers."""
from orisys_app_shared.force.calibration_io import (
CHANNEL_KEYS,
CalSample,
CalSessionStore,
build_force_calibration_v2,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
from orisys_app_shared.force.units import DisplayForces, select_display_forces
__all__ = [
"CHANNEL_KEYS",
"CalSample",
"CalSessionStore",
"DisplayForces",
"MetricHistory",
"PlotState",
"build_force_calibration_v2",
"export_force_calibration_v2",
"lowpass_filter",
"meta_from_sensor_json",
"select_display_forces",
]
@@ -0,0 +1,235 @@
"""Build and write schema_version-2 force_calibration.json files."""
from __future__ import annotations
import json
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Literal
ChannelKey = Literal["fn", "fx", "fy"]
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
_CHANNEL_TO_JSON = {
"fn": "normal",
"fx": "shear_x",
"fy": "shear_y",
}
_JSON_RAW_KEY = {
"normal": "fnormal",
"shear_x": "fshearx",
"shear_y": "fsheary",
}
WEIGHT_EPS = 1e-6
@dataclass
class CalSample:
weight_n: float
raw_mean: float
n: int
std: float = 0.0
@dataclass
class CalSessionStore:
"""In-memory calibration points for FN / FX / FY."""
fn: list[CalSample] = field(default_factory=list)
fx: list[CalSample] = field(default_factory=list)
fy: list[CalSample] = field(default_factory=list)
def channel(self, key: ChannelKey) -> list[CalSample]:
return getattr(self, key)
def set_channel(self, key: ChannelKey, samples: list[CalSample]) -> None:
setattr(self, key, list(samples))
def clear(self, key: ChannelKey | None = None) -> None:
if key is None:
self.fn.clear()
self.fx.clear()
self.fy.clear()
return
self.channel(key).clear()
def upsert(self, key: ChannelKey, sample: CalSample) -> None:
rows = self.channel(key)
for i, existing in enumerate(rows):
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
rows[i] = sample
break
else:
rows.append(sample)
rows.sort(key=lambda s: s.weight_n)
self.set_channel(key, rows)
def remove_at(self, key: ChannelKey, index: int) -> None:
rows = self.channel(key)
if 0 <= index < len(rows):
rows.pop(index)
def to_audit_dict(self) -> dict[str, list[dict[str, float | int]]]:
out: dict[str, list[dict[str, float | int]]] = {}
for key in CHANNEL_KEYS:
out[key] = [asdict(s) for s in self.channel(key)]
return out
def meta_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
"""Pull stitch / tracker fields from a sensor.json for export meta."""
meta: dict[str, Any] = {
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"config_ref": None,
}
if path is None:
return meta
p = Path(path)
meta["config_ref"] = str(p)
if not p.is_file():
return meta
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
return meta
if not isinstance(data, dict):
return meta
camera = data.get("camera") if isinstance(data.get("camera"), dict) else {}
tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {}
stitch = camera.get("resolution_stitch")
if isinstance(stitch, (list, tuple)) and len(stitch) >= 2:
meta["stitch_resolution"] = [int(stitch[0]), int(stitch[1])]
if "dis_mode" in tracker:
try:
meta["dis_mode"] = int(tracker["dis_mode"])
except (TypeError, ValueError):
meta["dis_mode"] = tracker.get("dis_mode")
if "hhd_downsample" in tracker:
try:
meta["hhd_downsample"] = int(tracker["hhd_downsample"])
except (TypeError, ValueError):
meta["hhd_downsample"] = tracker.get("hhd_downsample")
return meta
def _piecewise_channel(
*,
json_name: str,
samples: Iterable[CalSample],
signed: bool,
) -> dict[str, Any] | None:
rows = sorted(samples, key=lambda s: abs(float(s.raw_mean)))
if len(rows) < 2:
return None
points = [[abs(float(s.raw_mean)), float(s.weight_n)] for s in rows]
block: dict[str, Any] = {
"method": "piecewise_linear",
"raw_key": _JSON_RAW_KEY[json_name],
"extrapolate": True,
"clamp_output_min": 0.0,
"points": points,
}
if signed:
block["signed"] = True
return block
def build_force_calibration_v2(
store: CalSessionStore,
*,
meta: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Build schema v2 JSON. Returns (document, skipped_channel_warnings)."""
warnings: list[str] = []
channels: dict[str, Any] = {}
for key, json_name in _CHANNEL_TO_JSON.items():
signed = key in ("fx", "fy")
block = _piecewise_channel(
json_name=json_name,
samples=store.channel(key),
signed=signed,
)
if block is None:
if store.channel(key):
warnings.append(
f"{key}: need at least 2 points (have {len(store.channel(key))})"
)
else:
warnings.append(f"{key}: no samples")
continue
channels[json_name] = block
base_meta = {
"date": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"sensor_type": "",
"sn": "",
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"record_duration_s": None,
"config_ref": None,
"samples": store.to_audit_dict(),
}
if meta:
for k, v in meta.items():
if k == "samples":
continue
base_meta[k] = v
if "samples" in meta and meta["samples"] is not None:
base_meta["samples"] = meta["samples"]
doc = {
"schema_version": 2,
"unit": "N",
"channels": channels,
"meta": base_meta,
}
return doc, warnings
def write_force_calibration(
doc: dict[str, Any],
path: str | Path,
) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return path.resolve()
def export_force_calibration_v2(
store: CalSessionStore,
*,
export_dir: str | Path,
sensor_type: str,
meta: dict[str, Any] | None = None,
local_bundle_dir: str | Path | None = None,
) -> tuple[Path, Path | None, list[str]]:
"""Write timestamped export and optional local bundle force_calibration.json."""
doc, warnings = build_force_calibration_v2(store, meta=meta)
if not doc.get("channels"):
raise ValueError(
"No complete channels to export (each needs ≥2 points). "
+ "; ".join(warnings)
)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
export_path = Path(export_dir) / f"force_calibration_{sensor_type}_{stamp}.json"
primary = write_force_calibration(doc, export_path)
local_path: Path | None = None
if local_bundle_dir is not None:
bundle = Path(local_bundle_dir)
if bundle.is_dir():
local_path = write_force_calibration(
deepcopy(doc),
bundle / "force_calibration.json",
)
return primary, local_path, warnings
@@ -0,0 +1,150 @@
"""Rolling force history with optional display low-pass."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
@dataclass
class PlotState:
times: np.ndarray
values: np.ndarray
x_range: tuple[float, float]
y_ranges: tuple[tuple[float, float], ...]
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float,
order: int = 2,
) -> np.ndarray:
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0 or y.size == 0:
return y.copy()
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
return y.copy()
from scipy.signal import butter, filtfilt
b, a = butter(order, cutoff_hz / nyquist, btype="low")
# filtfilt requires length strictly greater than padlen.
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
try:
return filtfilt(b, a, y)
except ValueError:
return y.copy()
def _npoints_for_window(window_sec: float, sample_rate_hz: float) -> int:
return max(2, int(round(float(window_sec) * float(sample_rate_hz))))
class MetricHistory:
def __init__(
self,
npoints: int | None = None,
*,
channels: int = 3,
window_sec: float = 4.0,
sample_rate_hz: float | None = None,
lowpass_cutoff_hz: float = 0.0,
y_axis_min_span: float = 0.5,
):
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 (30.0 if self.window_sec <= 0 else 30.0)
)
if npoints is None:
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
else:
self.npoints = int(npoints)
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span)
self.reset()
def set_window_sec(self, window_sec: float) -> None:
self.window_sec = max(0.1, float(window_sec))
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
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:
raise ValueError(f"Expected {self.channels} values, got {len(values)}")
if self._t0 is None:
self._t0 = float(timestamp)
t = float(timestamp) - self._t0
row = (t, *map(float, values))
if self.sample_count < self.npoints:
self._data[self.sample_count, :] = row
self.sample_count += 1
return
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_range_smooth[channel] is None:
smoothed = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[channel]
ymin = 0.5 * ymin + 0.5 * prev_min
ymax = 0.5 * ymax + 0.5 * 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:
return None
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,
)
t_end = float(times[-1])
if t_end >= self.window_sec:
x_range = (t_end - self.window_sec, t_end)
else:
x_range = (0.0, self.window_sec)
y_ranges = []
for i in range(self.channels):
col = values[:, i]
ymin = float(np.nanmin(col))
ymax = float(np.nanmax(col))
if not np.isfinite(ymin) or not np.isfinite(ymax):
ymin, ymax = 0.0, 1.0
y_ranges.append(self._smooth_y_range(i, ymin, ymax))
return PlotState(
times=times,
values=values,
x_range=x_range,
y_ranges=tuple(y_ranges),
)
@@ -0,0 +1,46 @@
"""Force unit selection for display."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DisplayForces:
fn: float
fx: float
fy: float
unit: str
using_raw_fallback: bool
def select_display_forces(
*,
units_pref: str,
force_calibrated: bool,
fn: float,
fx: float,
fy: float,
fn_raw: float,
fx_raw: float,
fy_raw: float,
) -> DisplayForces:
"""Return values for plots/labels given newton/raw preference."""
want_newton = str(units_pref or "newton").lower().startswith("n")
if want_newton and force_calibrated:
return DisplayForces(fn=fn, fx=fx, fy=fy, unit="N", using_raw_fallback=False)
if want_newton and not force_calibrated:
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
unit="raw",
using_raw_fallback=True,
)
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
unit="raw",
using_raw_fallback=False,
)
@@ -0,0 +1,5 @@
"""i18n helpers."""
from orisys_app_shared.i18n.translator import Translator, get_translator, set_locale, t
__all__ = ["Translator", "get_translator", "set_locale", "t"]
@@ -0,0 +1,80 @@
"""Simple JSON locale translator with en_US fallback."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from orisys_app_shared.paths import locales_dir
class Translator:
def __init__(self, locales_path: Path | None = None, locale: str = "zh_CN") -> None:
self._locales_path = locales_path or locales_dir()
self._locale = locale
self._strings: dict[str, Any] = {}
self._fallback: dict[str, Any] = {}
self.set_locale(locale)
@property
def locale(self) -> str:
return self._locale
def set_locale(self, locale: str) -> None:
self._locale = locale
self._fallback = self._load("en_US")
self._strings = self._load(locale)
def _load(self, locale: str) -> dict[str, Any]:
path = self._locales_path / f"{locale}.json"
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def t(self, key: str, **kwargs: Any) -> str:
value = self._lookup(self._strings, key)
if value is None:
value = self._lookup(self._fallback, key)
if value is None:
return key
text = str(value)
if kwargs:
try:
return text.format(**kwargs)
except (KeyError, ValueError):
return text
return text
@staticmethod
def _lookup(data: dict[str, Any], key: str) -> Any | None:
node: Any = data
for part in key.split("."):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node
_translator: Translator | None = None
def get_translator() -> Translator:
global _translator
if _translator is None:
_translator = Translator()
return _translator
def t(key: str, **kwargs: Any) -> str:
return get_translator().t(key, **kwargs)
def set_locale(locale: str) -> Translator:
tr = get_translator()
tr.set_locale(locale)
return tr
@@ -0,0 +1,14 @@
"""Logging helpers."""
from orisys_app_shared.logging.errors import report_error, report_warning
from orisys_app_shared.logging.excepthook import install_exception_hooks
from orisys_app_shared.logging.qt_handler import QtLogHandler
from orisys_app_shared.logging.setup import init_logging
__all__ = [
"QtLogHandler",
"init_logging",
"install_exception_hooks",
"report_error",
"report_warning",
]
@@ -0,0 +1,29 @@
"""User/developer error reporting helpers."""
from __future__ import annotations
import logging
import traceback
from typing import Any
def report_error(
exc: BaseException | None = None,
*,
message: str = "",
logger_name: str = "orisys_app",
) -> None:
"""Log an error with optional exception traceback (dialog wiring later)."""
logger = logging.getLogger(logger_name)
if exc is not None:
logger.error(
"%s\n%s",
message or str(exc),
"".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
)
else:
logger.error("%s", message or "Unknown error")
def report_warning(message: str, *, logger_name: str = "orisys_app", **_kwargs: Any) -> None:
logging.getLogger(logger_name).warning("%s", message)
@@ -0,0 +1,38 @@
"""Install process-wide exception hooks that log tracebacks."""
from __future__ import annotations
import logging
import sys
import threading
import traceback
def install_exception_hooks() -> None:
logger = logging.getLogger("orisys_app_shared.excepthook")
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logger.error(
"Unhandled exception\n%s",
"".join(traceback.format_exception(exc_type, exc_value, exc_traceback)),
)
sys.excepthook = handle_exception
if hasattr(threading, "excepthook"):
def thread_hook(args):
if args.exc_type is None:
return
if issubclass(args.exc_type, SystemExit):
return
logger.error(
"Unhandled thread exception in %s\n%s",
getattr(args.thread, "name", "?"),
"".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)),
)
threading.excepthook = thread_hook
@@ -0,0 +1,25 @@
"""Qt log handler stub for a future log dock."""
from __future__ import annotations
import logging
from typing import Callable
class QtLogHandler(logging.Handler):
"""Forward log records to a callable (e.g. append to a QTextEdit)."""
def __init__(self, sink: Callable[[str], None] | None = None) -> None:
super().__init__()
self._sink = sink
def set_sink(self, sink: Callable[[str], None] | None) -> None:
self._sink = sink
def emit(self, record: logging.LogRecord) -> None:
if self._sink is None:
return
try:
self._sink(self.format(record))
except Exception:
self.handleError(record)
@@ -0,0 +1,42 @@
"""Logging setup for orisys-app products."""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from orisys_app_shared.paths import logs_dir
_INITIALIZED = False
def init_logging(app_name: str = "orisys-app", *, level: int = logging.INFO) -> Path:
"""Configure root logger with console + session file under outputs/logs."""
global _INITIALIZED
log_path = logs_dir() / f"{app_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers on repeated init (tests / re-entry).
if _INITIALIZED:
return log_path
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setFormatter(formatter)
file_handler.setLevel(level)
root.addHandler(file_handler)
console = logging.StreamHandler()
console.setFormatter(formatter)
console.setLevel(level)
root.addHandler(console)
_INITIALIZED = True
logging.getLogger(__name__).info("Logging initialized: %s", log_path)
return log_path
+152
View File
@@ -0,0 +1,152 @@
"""Resolve app, config, and output paths for editable and frozen runs."""
from __future__ import annotations
import sys
from pathlib import Path
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _customer_root_from_shared() -> Path:
"""Repo layout: shared/src/orisys_app_shared -> apps/customer."""
here = Path(__file__).resolve()
# .../orisys-app/shared/src/orisys_app_shared/paths.py
repo_root = here.parents[3]
return repo_root / "apps" / "customer"
def app_root() -> Path:
"""Return the customer app root (editable) or EXE directory (frozen)."""
if is_frozen():
return Path(sys.executable).resolve().parent
# Prefer cwd override when launched from apps/customer
cwd = Path.cwd().resolve()
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
return cwd
if (cwd / "apps" / "customer" / "config").is_dir():
return cwd / "apps" / "customer"
return _customer_root_from_shared()
def config_dir() -> Path:
if is_frozen():
beside = app_root() / "config"
if beside.is_dir():
return beside
internal = app_root() / "_internal" / "config"
if internal.is_dir():
return internal
return app_root() / "config"
def sensors_dir() -> Path:
return config_dir() / "sensors"
def locales_dir() -> Path:
return config_dir() / "locales"
def outputs_dir() -> Path:
path = app_root() / "outputs"
path.mkdir(parents=True, exist_ok=True)
return path
def logs_dir() -> Path:
path = outputs_dir() / "logs"
path.mkdir(parents=True, exist_ok=True)
return path
def calibration_export_dir() -> Path:
path = outputs_dir() / "calibration"
path.mkdir(parents=True, exist_ok=True)
return path
def preference_path() -> Path:
return app_root() / "preference.json"
def preferences_default_path() -> Path:
return config_dir() / "preferences.default.json"
def branding_dir() -> Path:
"""Branding assets shipped with the shared package."""
# editable: shared/resources/branding
# package: next to installed module via package resources fallback
here = Path(__file__).resolve()
candidates = [
here.parents[2] / "resources" / "branding", # shared/resources/branding
here.parent / "resources" / "branding",
]
if is_frozen():
candidates.insert(0, app_root() / "branding")
candidates.insert(0, app_root() / "_internal" / "branding")
for path in candidates:
if path.is_dir():
return path
return candidates[0]
def logo_path() -> Path:
return branding_dir() / "logo.png"
def logo_topbar_path() -> Path:
return branding_dir() / "logo_topbar.png"
def sensor_json_path(sensor_type: str) -> Path | None:
"""Return ``config/sensors/<type>/sensor.json`` when present."""
path = sensors_dir() / str(sensor_type) / "sensor.json"
return path if path.is_file() else None
_PREFERRED_SENSOR_ROOT: Path | None = None
def prefer_local_sensor_bundles(root: Path | None = None) -> Path | None:
"""Prefer app ``config/sensors`` over packaged Orisys sensor bundles."""
global _PREFERRED_SENSOR_ROOT
if root is not None:
sensors = (Path(root) / "config" / "sensors").resolve()
if not sensors.is_dir():
sensors = Path(root).resolve()
else:
sensors = sensors_dir().resolve()
if not sensors.is_dir():
return None
if _PREFERRED_SENSOR_ROOT is not None and _PREFERRED_SENSOR_ROOT == sensors:
return sensors
try:
import orisys.configs as configs
except Exception:
return None
original = configs._candidate_sensor_roots
marker = str(sensors)
if getattr(original, "_orisys_app_local_pref", None) == marker:
_PREFERRED_SENSOR_ROOT = sensors
return sensors
def _candidate_sensor_roots_prefer_local() -> list[Path]:
roots = [sensors]
for candidate in original():
resolved = Path(candidate).resolve()
if resolved != sensors and resolved not in roots:
roots.append(resolved)
return roots
_candidate_sensor_roots_prefer_local._orisys_app_local_pref = marker # type: ignore[attr-defined]
configs._candidate_sensor_roots = _candidate_sensor_roots_prefer_local
_PREFERRED_SENSOR_ROOT = sensors
return sensors
@@ -0,0 +1,17 @@
"""Preference store re-exports."""
from orisys_app_shared.prefs.store import (
default_preferences,
get_preference,
load_preferences,
save_preferences,
set_preference,
)
__all__ = [
"default_preferences",
"get_preference",
"load_preferences",
"save_preferences",
"set_preference",
]
@@ -0,0 +1,70 @@
"""Load/save preference.json with defaults merge."""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
from orisys_app_shared.paths import preference_path, preferences_default_path
_DEFAULTS: dict[str, Any] = {
"language": "zh_CN",
"theme": "dark",
"window_geometry": None,
"camera_id": "0",
"source_mode": "camera",
"video_path": "",
"show_cal_panel": False,
"show_finger_3d": True,
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"image_view_mode": "arrows_pure",
}
def _load_json(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def default_preferences() -> dict[str, Any]:
merged = deepcopy(_DEFAULTS)
merged.update(_load_json(preferences_default_path()))
return merged
def load_preferences() -> dict[str, Any]:
prefs = default_preferences()
prefs.update(_load_json(preference_path()))
return prefs
def save_preferences(prefs: dict[str, Any]) -> Path:
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload = default_preferences()
payload.update(prefs)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return path
def get_preference(key: str, default: Any = None) -> Any:
return load_preferences().get(key, default)
def set_preference(key: str, value: Any) -> dict[str, Any]:
prefs = load_preferences()
prefs[key] = value
save_preferences(prefs)
return prefs
@@ -0,0 +1,5 @@
"""Sensor catalog helpers."""
from orisys_app_shared.sensors.catalog import list_configs_for_type, list_sensor_types
__all__ = ["list_configs_for_type", "list_sensor_types"]
@@ -0,0 +1,33 @@
"""List local editable sensor bundles under config/sensors."""
from __future__ import annotations
from pathlib import Path
from orisys_app_shared.paths import sensors_dir
PRODUCT_TYPES = ("box", "mini", "finger")
def list_sensor_types() -> list[str]:
return list(PRODUCT_TYPES)
def list_configs_for_type(sensor_type: str) -> list[tuple[str, str]]:
"""Return ``(display_name, config_key)`` entries for one sensor type.
Prefers ``config/sensors/<type>/sensor.json`` when present; otherwise a
single default entry named after the type (for prefs / future Run).
"""
name = str(sensor_type or "").strip()
if not name:
return []
root = sensors_dir() / name
sensor_json = root / "sensor.json"
if sensor_json.is_file():
return [(f"{name}/sensor.json", str(sensor_json))]
# Also accept extra *.json next to sensor.json later; for now only sensor.json.
extras = sorted(p for p in root.glob("*.json") if p.name != "manifest.json")
if extras:
return [(f"{name}/{p.name}", str(p)) for p in extras]
return [(name, name)]
@@ -0,0 +1,5 @@
"""Theme helpers."""
from orisys_app_shared.theme.qss import build_qss, clear_theme_cache, color, load_colors
__all__ = ["build_qss", "clear_theme_cache", "color", "load_colors"]
@@ -0,0 +1,23 @@
{
"window_bg": "#121316",
"panel_bg": "#1a1c20",
"panel_border": "#2a2d33",
"text_primary": "#e8eaed",
"text_secondary": "#9aa0a6",
"accent": "#536dce",
"accent_hover": "#6b82d9",
"button_bg": "#2a2d33",
"button_hover": "#3a3f47",
"input_bg": "#0f1012",
"input_border": "#3a3f47",
"menu_bg": "#1a1c20",
"menu_hover": "#2a2d33",
"plot_fn": "#8ab4f8",
"plot_fx": "#34a853",
"plot_fy": "#fbbc04",
"plot_bg": "#121316",
"plot_grid": "#2a2d33",
"arrow_canvas": "#121316",
"danger": "#ea4335",
"success": "#34a853"
}
@@ -0,0 +1,23 @@
{
"window_bg": "#f5f6f8",
"panel_bg": "#ffffff",
"panel_border": "#d0d4dc",
"text_primary": "#1a1c20",
"text_secondary": "#5f6368",
"accent": "#536dce",
"accent_hover": "#3f56b5",
"button_bg": "#e8eaed",
"button_hover": "#d0d4dc",
"input_bg": "#ffffff",
"input_border": "#c0c4cc",
"menu_bg": "#ffffff",
"menu_hover": "#e8eaed",
"plot_fn": "#1a73e8",
"plot_fx": "#188038",
"plot_fy": "#f9ab00",
"plot_bg": "#ffffff",
"plot_grid": "#e0e3e8",
"arrow_canvas": "#eef0f3",
"danger": "#d93025",
"success": "#188038"
}
+105
View File
@@ -0,0 +1,105 @@
"""Build Qt stylesheets from theme color token JSON (no hex in widgets)."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
def _theme_dir() -> Path:
return Path(__file__).resolve().parent
@lru_cache(maxsize=4)
def load_colors(theme: str = "dark") -> dict[str, str]:
name = "colors_dark.json" if theme != "light" else "colors_light.json"
path = _theme_dir() / name
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Invalid theme file: {path}")
return {str(k): str(v) for k, v in data.items()}
def build_qss(theme: str = "dark") -> str:
c = load_colors(theme)
return f"""
QWidget {{
background-color: {c['window_bg']};
color: {c['text_primary']};
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
}}
QMainWindow, QDialog {{
background-color: {c['window_bg']};
}}
QMenuBar {{
background-color: {c['menu_bg']};
color: {c['text_primary']};
border-bottom: 1px solid {c['panel_border']};
}}
QMenuBar::item:selected {{
background-color: {c['menu_hover']};
}}
QMenu {{
background-color: {c['menu_bg']};
color: {c['text_primary']};
border: 1px solid {c['panel_border']};
}}
QMenu::item:selected {{
background-color: {c['menu_hover']};
}}
QToolBar {{
background-color: {c['panel_bg']};
border-bottom: 1px solid {c['panel_border']};
spacing: 8px;
padding: 4px;
}}
QPushButton {{
background-color: {c['button_bg']};
color: {c['text_primary']};
border: 1px solid {c['panel_border']};
border-radius: 4px;
padding: 6px 12px;
}}
QPushButton:hover {{
background-color: {c['button_hover']};
}}
QPushButton:disabled {{
color: {c['text_secondary']};
}}
QComboBox, QLineEdit, QSpinBox {{
background-color: {c['input_bg']};
color: {c['text_primary']};
border: 1px solid {c['input_border']};
border-radius: 4px;
padding: 4px 8px;
min-height: 24px;
}}
QLabel#panelTitle {{
color: {c['text_secondary']};
font-weight: 600;
}}
QLabel#panelMeta {{
color: {c['text_secondary']};
font-weight: 500;
padding-right: 8px;
}}
QFrame#contentPanel {{
background-color: {c['panel_bg']};
border: 1px solid {c['panel_border']};
border-radius: 6px;
}}
QStatusBar {{
background-color: {c['panel_bg']};
color: {c['text_secondary']};
}}
"""
def color(theme: str, key: str, default: str = "#888888") -> str:
return load_colors(theme).get(key, default)
def clear_theme_cache() -> None:
load_colors.cache_clear()