initial commit, functinal software
This commit is contained in:
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user