重构 Autocal 采集页:单一队列与控制器驱动的主按钮
选点、对准、加载与录像分散在两个面板,状态靠隐藏相位判断。现改由不依赖 Qt 的采集控制器驱动,并加入录像选项、汇总平铺副本、保持到时自动结束、会话结束回到空闲,以及无设备时的模拟采集。已运行 Autocal 非 Qt 测试并通过范围内门禁;界面测试待 Windows 复核,未做实机验收。
This commit is contained in:
@@ -8,6 +8,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from .recording_options import RecordingOptions
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict) -> None:
|
||||
"""Replace a manifest atomically, keeping the previous version on failure."""
|
||||
@@ -31,11 +33,15 @@ class CalibrationSession:
|
||||
identity.append(value)
|
||||
self.directory = root / f"{'_'.join(identity)}_{stamp}_{uuid4().hex[:12]}"
|
||||
self.identity = self.identity_of(metadata)
|
||||
#: The video choice belongs to the session, not to each trial: it is
|
||||
#: written once here and never rewritten by a later ``add_trial``.
|
||||
self.recording = RecordingOptions.from_block(metadata.get("recording"))
|
||||
self.document = {
|
||||
"format_version": "autocal-session-1.0",
|
||||
"session_id": self.directory.name,
|
||||
**self.identity,
|
||||
"config_snapshot": metadata["config_snapshot"],
|
||||
"recording": self.recording.as_block(),
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "in_progress",
|
||||
"sites": [],
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
"""The capture page's workflow controller.
|
||||
|
||||
The controller owns the operator's next step. It wraps one
|
||||
:class:`~orisys_autocal.multipoint.Campaign` (the point queue) and turns the
|
||||
campaign's state into the single primary button the page shows, plus the three
|
||||
secondary actions. It is deliberately headless — no Qt and no motion service — so
|
||||
the whole decision can be exercised without a window, a camera or a controller.
|
||||
|
||||
Aligning a point is only ever *requested*: the request is published on
|
||||
:attr:`CaptureController.pending_alignment` and the window plans the move. The
|
||||
controller cannot command motion even by accident.
|
||||
|
||||
The queue is edited through the controller too: adding, updating, removing and
|
||||
reordering a point, and storing the reference rotation, all go through its
|
||||
methods, and :meth:`model` returns every field the page renders. That keeps the
|
||||
page from reading ``campaign.sites`` or writing a ``SiteSpec``.
|
||||
|
||||
The public contract is frozen in
|
||||
``.scratch/autocal-capture-workflow/interfaces.md`` section 1. Two facts the
|
||||
controller does not invent for itself:
|
||||
|
||||
* the arguments :meth:`Campaign.start` needs (output root, sensor model and SN,
|
||||
coordinate frame, simulate flag) are supplied by :attr:`start_arguments`,
|
||||
because they are the window's current state, not the campaign's;
|
||||
* whether a point must be aligned first is answered by ``alignment_required``. The
|
||||
default is yes; the page says no only for a simulated session, which commands no
|
||||
axis and so needs no platform, bus or surface solver;
|
||||
* the maximum force is the controller's own ``max_force_n`` and is the only
|
||||
source of the sixth readiness check. Before a session exists the campaign has
|
||||
no maximum force, and after it starts the value has already been validated.
|
||||
|
||||
The "this point is aligned" flag is what separates :meth:`align_point` from the
|
||||
other primary actions. Only the window may set it, through
|
||||
:meth:`mark_aligned`, after the requested move has actually finished: a request
|
||||
can be refused, cancelled or fail, and starting a session on the old pose would
|
||||
record the wrong geometry. The flag is cleared when a *different* point comes
|
||||
into focus (the start of a session and each point change), never after
|
||||
``campaign.reacquire()``: the platform is already on that point and recording it
|
||||
again does not need another move.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from .multipoint import Campaign
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only, keeps the motion package out
|
||||
from .motion.kinematics import PlatformStack
|
||||
|
||||
#: Readiness rows in the frozen order. The label keys are the page's, not this
|
||||
#: module's: a locale translation of a reason is never stored in the controller.
|
||||
READINESS_ITEMS: tuple[tuple[str, str], ...] = (
|
||||
("calibration_bundle", "cap.check.bundle"),
|
||||
("input_frames", "cap.check.frames"),
|
||||
("bus_ready", "cap.check.bus"),
|
||||
("output_writable", "cap.check.output"),
|
||||
("sensor_sn", "cap.check.sn"),
|
||||
("max_force", "cap.check.max_force"),
|
||||
)
|
||||
|
||||
READINESS_KEYS: tuple[str, ...] = tuple(key for key, _label in READINESS_ITEMS)
|
||||
|
||||
_ACTION_LABELS = {
|
||||
"align_point": "cap.action.align",
|
||||
"start_session": "cap.action.start",
|
||||
"confirm_target": "cap.action.confirm_target",
|
||||
"confirm_hold": "cap.action.confirm_hold",
|
||||
"confirm_liftoff": "cap.action.confirm_liftoff",
|
||||
"finish_session": "cap.action.finish",
|
||||
"closing": "cap.block.closing",
|
||||
"remeasure": "cap.action.remeasure",
|
||||
"skip": "cap.action.skip",
|
||||
"stop": "cap.action.stop",
|
||||
}
|
||||
|
||||
|
||||
class ActionBlocked(RuntimeError):
|
||||
"""An action exists but cannot run yet.
|
||||
|
||||
``reason`` is a locale key (``cap.block.*``), never translated text; the page
|
||||
owns the translation. ``label_key`` names the action that was refused.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, label_key: str = "") -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
self.label_key = label_key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadinessCheck:
|
||||
"""One external fact the capture page needs before it may start."""
|
||||
|
||||
key: str
|
||||
ok: bool
|
||||
label_key: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class PrimaryAction(NamedTuple):
|
||||
key: str
|
||||
label_key: str
|
||||
enabled: bool
|
||||
blocked_reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlignmentRequest:
|
||||
"""The five-axis targets for one point, for the window's move planner."""
|
||||
|
||||
site_id: str
|
||||
targets: dict[str, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SiteRow:
|
||||
"""One row of the point queue, as the page renders it.
|
||||
|
||||
Everything the queue table and the point editor may show is copied into the
|
||||
row, so the page never reads ``campaign.sites`` or a ``SiteSpec`` field.
|
||||
``pose`` is a snapshot: a display or comparison value, not a handle the page
|
||||
may write through.
|
||||
"""
|
||||
|
||||
site_id: str
|
||||
position: dict[str, float]
|
||||
repeats_planned: int
|
||||
repeats_complete: int
|
||||
complete: bool
|
||||
reachable: bool
|
||||
levels: tuple[float, ...]
|
||||
hold_s: float
|
||||
uv_px: tuple[float, float] | None
|
||||
normal: tuple[float, float, float] | None
|
||||
pose: dict | None
|
||||
position_frame: str
|
||||
skipped: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaptureModel:
|
||||
"""Everything the page may read about the campaign, in one snapshot."""
|
||||
|
||||
sites: list[SiteRow]
|
||||
current_index: int
|
||||
progress: dict
|
||||
|
||||
|
||||
class CaptureController:
|
||||
"""Walk one campaign through the operator's steps without touching Qt or motion."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
campaign: Campaign,
|
||||
*,
|
||||
solver: PlatformStack | None = None,
|
||||
readiness: Callable[[], list[ReadinessCheck]] | None = None,
|
||||
max_force_n: float | None = None,
|
||||
start_arguments: Callable[[], dict] | None = None,
|
||||
alignment_required: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
self.campaign = campaign
|
||||
self.solver = solver
|
||||
self._readiness = readiness
|
||||
self._max_force_n = None if max_force_n is None else float(max_force_n)
|
||||
self._start_arguments = start_arguments
|
||||
self._alignment_probe = alignment_required
|
||||
self._pending_alignment: AlignmentRequest | None = None
|
||||
self._aligned: set[str] = set()
|
||||
self._skipped: set[str] = set()
|
||||
self._focus_site = ""
|
||||
|
||||
# ------------------------------------------------------------------ readiness
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return all(check.ok for check in self.readiness())
|
||||
|
||||
def readiness(self) -> list[ReadinessCheck]:
|
||||
"""All six checks, in the frozen order, including the ones that failed."""
|
||||
probe = self._probe_checks()
|
||||
result: list[ReadinessCheck] = []
|
||||
for key, label in READINESS_ITEMS:
|
||||
if key == "max_force":
|
||||
result.append(self._max_force_check(key, label))
|
||||
else:
|
||||
result.append(probe[key])
|
||||
return result
|
||||
|
||||
def _max_force_check(self, key: str, label: str) -> ReadinessCheck:
|
||||
value = self._max_force_n
|
||||
ok = value is not None and math.isfinite(value) and value > 0
|
||||
return ReadinessCheck(key, ok, label, "" if ok else "max_force_n is not set")
|
||||
|
||||
def _probe_checks(self) -> dict[str, ReadinessCheck]:
|
||||
"""Map the injected probe's answer onto the six frozen keys.
|
||||
|
||||
A probe that raises or omits a key must not take the page down: the
|
||||
missing rows are reported as *not* ready with their cause, because an
|
||||
unreported fact is not a satisfied one.
|
||||
"""
|
||||
if self._readiness is None:
|
||||
return {
|
||||
key: ReadinessCheck(key, True, label) for key, label in READINESS_ITEMS
|
||||
}
|
||||
try:
|
||||
supplied = list(self._readiness())
|
||||
except Exception as exc: # noqa: BLE001 - a probe is external and must not crash the page
|
||||
detail = str(exc) or type(exc).__name__
|
||||
return {
|
||||
key: ReadinessCheck(key, False, label, detail)
|
||||
for key, label in READINESS_ITEMS
|
||||
}
|
||||
reported = {
|
||||
str(getattr(check, "key", "")): check
|
||||
for check in supplied
|
||||
if getattr(check, "key", "")
|
||||
}
|
||||
rows: dict[str, ReadinessCheck] = {}
|
||||
for key, label in READINESS_ITEMS:
|
||||
check = reported.get(key)
|
||||
if check is None:
|
||||
rows[key] = ReadinessCheck(
|
||||
key, False, label, "the readiness probe did not report this check"
|
||||
)
|
||||
else:
|
||||
rows[key] = ReadinessCheck(
|
||||
key, bool(check.ok), label, str(getattr(check, "detail", "") or "")
|
||||
)
|
||||
return rows
|
||||
|
||||
# ------------------------------------------------------------------ primary action
|
||||
|
||||
def primary_action(self) -> PrimaryAction:
|
||||
"""The one button the page shows now, with a reason whenever it is disabled."""
|
||||
self._sync_alignment_focus()
|
||||
campaign = self.campaign
|
||||
phase = campaign.phase
|
||||
if self._closing():
|
||||
return PrimaryAction(
|
||||
"closing", "cap.block.closing", False, "cap.block.closing"
|
||||
)
|
||||
if phase == "idle":
|
||||
if not campaign.sites:
|
||||
return self._start_action()
|
||||
# A session that moves nothing has no pose to reach first.
|
||||
if not self._alignment_required() or self._aligned_in_focus():
|
||||
return self._start_action()
|
||||
return self._align_action()
|
||||
if phase == "liftoff":
|
||||
pending = campaign.pending_alignment_site
|
||||
if (
|
||||
pending
|
||||
and self._alignment_required()
|
||||
and not self._is_aligned(pending)
|
||||
):
|
||||
action = self._align_action()
|
||||
if action.enabled:
|
||||
return action
|
||||
return PrimaryAction(
|
||||
"confirm_liftoff", "cap.action.confirm_liftoff", True, ""
|
||||
)
|
||||
if phase == "load":
|
||||
return PrimaryAction(
|
||||
"confirm_target", "cap.action.confirm_target", True, ""
|
||||
)
|
||||
if phase == "hold":
|
||||
if campaign.remaining_s() > 0:
|
||||
return PrimaryAction(
|
||||
"confirm_hold",
|
||||
"cap.action.confirm_hold",
|
||||
False,
|
||||
"cap.block.hold_running",
|
||||
)
|
||||
return PrimaryAction("confirm_hold", "cap.action.confirm_hold", True, "")
|
||||
if phase in {"done", "aborted"}:
|
||||
return PrimaryAction("finish_session", "cap.action.finish", True, "")
|
||||
# An unknown phase is never actionable; say the repeat is being written
|
||||
# rather than inventing a button that would do nothing.
|
||||
return PrimaryAction("closing", "cap.block.closing", False, "cap.block.busy")
|
||||
|
||||
def _alignment_required(self) -> bool:
|
||||
"""Whether a point must be aligned before it is recorded.
|
||||
|
||||
A simulated session commands no axis, so the page says alignment is not
|
||||
needed and the platform, the bus and the surface solver can all be absent.
|
||||
Without that answer (no probe, or one that fails) alignment is required: the
|
||||
stricter reading is the safe one.
|
||||
"""
|
||||
if self._alignment_probe is None:
|
||||
return True
|
||||
try:
|
||||
return bool(self._alignment_probe())
|
||||
except Exception: # noqa: BLE001 - the page's own state must not break the decision
|
||||
return True
|
||||
|
||||
def _start_action(self) -> PrimaryAction:
|
||||
campaign = self.campaign
|
||||
if not campaign.sites:
|
||||
return self._disabled("start_session", "cap.block.no_sites")
|
||||
if self._start_arguments is None:
|
||||
return self._disabled("start_session", "cap.block.no_session")
|
||||
if not self.ready:
|
||||
return self._disabled("start_session", "cap.block.not_ready")
|
||||
return PrimaryAction("start_session", "cap.action.start", True, "")
|
||||
|
||||
def _align_action(self) -> PrimaryAction:
|
||||
if self._recording():
|
||||
return self._disabled("align_point", "cap.block.align_while_recording")
|
||||
if self.solver is None:
|
||||
return self._disabled("align_point", "cap.block.no_solver")
|
||||
site_id = self._focus_site
|
||||
if not site_id or not self._pose_usable(site_id):
|
||||
return self._disabled("align_point", "cap.block.no_pose")
|
||||
return PrimaryAction("align_point", "cap.action.align", True, "")
|
||||
|
||||
@staticmethod
|
||||
def _disabled(key: str, reason: str) -> PrimaryAction:
|
||||
return PrimaryAction(key, _ACTION_LABELS[key], False, reason)
|
||||
|
||||
# ------------------------------------------------------------------ secondary actions
|
||||
|
||||
def secondary_actions(self) -> list[PrimaryAction]:
|
||||
"""Remeasure, skip and stop, in that frozen order."""
|
||||
self._sync_alignment_focus()
|
||||
return [self._remeasure_action(), self._skip_action(), self._stop_action()]
|
||||
|
||||
def _remeasure_action(self) -> PrimaryAction:
|
||||
enabled = self.campaign.can_remeasure_step()
|
||||
return PrimaryAction(
|
||||
"remeasure",
|
||||
"cap.action.remeasure",
|
||||
enabled,
|
||||
"" if enabled else "cap.block.remeasure_unavailable",
|
||||
)
|
||||
|
||||
def _skip_action(self) -> PrimaryAction:
|
||||
if not hasattr(self.campaign, "skip_current_point"):
|
||||
return self._disabled("skip", "cap.block.skip_unavailable")
|
||||
if self._recording():
|
||||
return self._disabled("skip", "cap.block.skip_live")
|
||||
if self.campaign.phase not in {"load", "hold"}:
|
||||
return self._disabled("skip", "cap.block.busy")
|
||||
return PrimaryAction("skip", "cap.action.skip", True, "")
|
||||
|
||||
def _stop_action(self) -> PrimaryAction:
|
||||
running = self.campaign.is_active or self.campaign.busy
|
||||
return PrimaryAction(
|
||||
"stop",
|
||||
"cap.action.stop",
|
||||
running,
|
||||
"" if running else "cap.block.stop_unavailable",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ perform
|
||||
|
||||
def perform(self, key: str, **inputs) -> None:
|
||||
"""Run one offered action, or explain in a locale key why it cannot run."""
|
||||
# A recording repeat must never be interrupted by a move, and a caller can
|
||||
# ask for it directly instead of through the button. This guard outranks
|
||||
# the "not the current action" check, so the refusal names the real cause.
|
||||
if key == "align_point" and self._recording():
|
||||
raise ActionBlocked("cap.block.align_while_recording", "cap.action.align")
|
||||
self._sync_alignment_focus()
|
||||
primary = self.primary_action()
|
||||
secondary = {action.key: action for action in self.secondary_actions()}
|
||||
if key == primary.key:
|
||||
action = primary
|
||||
elif key in secondary:
|
||||
action = secondary[key]
|
||||
else:
|
||||
raise ValueError(f"{key!r} is not an offered capture action")
|
||||
if not action.enabled:
|
||||
raise ActionBlocked(action.blocked_reason, action.label_key)
|
||||
if inputs and key not in {"confirm_target", "stop"}:
|
||||
raise ValueError(f"{key} takes no inputs")
|
||||
if key == "start_session":
|
||||
self._perform_start_session()
|
||||
elif key == "align_point":
|
||||
self._perform_align_point()
|
||||
elif key == "confirm_target":
|
||||
self._perform_confirm_target(inputs)
|
||||
elif key == "confirm_hold":
|
||||
self.campaign.complete_hold()
|
||||
elif key == "confirm_liftoff":
|
||||
self.campaign.confirm_liftoff()
|
||||
elif key == "finish_session":
|
||||
self._perform_finish_session()
|
||||
elif key == "remeasure":
|
||||
self.campaign.remeasure_step()
|
||||
elif key == "skip":
|
||||
self._perform_skip()
|
||||
elif key == "stop":
|
||||
self._perform_stop(inputs)
|
||||
else: # pragma: no cover - "closing" and unknown keys never reach here
|
||||
raise ValueError(f"{key!r} is not an executable capture action")
|
||||
|
||||
def _perform_finish_session(self) -> None:
|
||||
"""Close the finished session out so the queue is editable again.
|
||||
|
||||
``flush_close`` is not what ends a session: it only waits for a writer that
|
||||
is still closing, and ``primary_action`` never offers this key while one is.
|
||||
What the operator needs is the way back to the idle phase, with the next
|
||||
session possible and the finished one's files left exactly as they are.
|
||||
"""
|
||||
self.campaign.end_session()
|
||||
# The platform stayed where the last point left it, and the queue may be
|
||||
# edited next: nothing is aligned until a move says so.
|
||||
self._aligned.clear()
|
||||
self._pending_alignment = None
|
||||
|
||||
def _perform_start_session(self) -> None:
|
||||
arguments = self._start_arguments
|
||||
if arguments is None: # pragma: no cover - the action is disabled without it
|
||||
raise ActionBlocked("cap.block.no_session", "cap.action.start")
|
||||
self._pending_alignment = None
|
||||
self.campaign.start(**arguments())
|
||||
# A new session measures every point again: skip marks belong to the
|
||||
# session that recorded them.
|
||||
self._skipped.clear()
|
||||
|
||||
def _perform_align_point(self) -> None:
|
||||
"""Publish the move request. It does not mean the platform has moved."""
|
||||
site_id = self._focus_site
|
||||
try:
|
||||
targets = dict(self.campaign.command_targets(site_id))
|
||||
except ValueError as exc:
|
||||
raise ActionBlocked("cap.block.no_pose", "cap.action.align") from exc
|
||||
self.pending_alignment = AlignmentRequest(site_id=site_id, targets=targets)
|
||||
|
||||
def _perform_confirm_target(self, inputs: dict) -> None:
|
||||
force_n = inputs.pop("force_n", None)
|
||||
wrench = inputs.pop("wrench", None)
|
||||
rotation = inputs.pop("rotation", None)
|
||||
if inputs:
|
||||
raise ValueError("confirm_target accepts force_n or wrench, nothing else")
|
||||
if (force_n is None) == (wrench is None):
|
||||
raise ValueError("confirm_target needs exactly one of force_n or wrench")
|
||||
if force_n is not None:
|
||||
if rotation is not None:
|
||||
raise ValueError("rotation only applies to a six-axis reference")
|
||||
self.campaign.confirm_reference(float(force_n))
|
||||
return
|
||||
values = tuple(float(value) for value in wrench)
|
||||
if len(values) != 3:
|
||||
raise ValueError("wrench needs three components")
|
||||
if rotation is not None:
|
||||
# ``Campaign.start`` cleared the rotation; the only moment the operator
|
||||
# enters it is this confirmation, so set it right before the reading.
|
||||
self.campaign.set_force_rotation([float(value) for value in rotation])
|
||||
self.campaign.confirm_wrench(values[0], values[1], values[2])
|
||||
|
||||
def _perform_skip(self) -> None:
|
||||
method = getattr(self.campaign, "skip_current_point", None)
|
||||
if method is None: # pragma: no cover - the action is disabled without it
|
||||
raise ActionBlocked("cap.block.skip_unavailable", "cap.action.skip")
|
||||
site_id = self._focus_site
|
||||
method()
|
||||
if site_id:
|
||||
# The queue still lists the point; the row says it was skipped so the
|
||||
# operator can see it was not recorded rather than guess from a count.
|
||||
self._skipped.add(site_id)
|
||||
|
||||
def _perform_stop(self, inputs: dict) -> None:
|
||||
"""Stop the session, with an optional free-text reason for the record.
|
||||
|
||||
``session.json``'s ``reason`` is free text, not a locale key: the stop
|
||||
button keeps the default phrase, while the window's shutdown path passes
|
||||
its own so an application close stays distinguishable from an operator
|
||||
stop in the data.
|
||||
"""
|
||||
reason = inputs.pop("reason", None)
|
||||
if inputs:
|
||||
raise ValueError("stop accepts a reason only")
|
||||
if reason is None:
|
||||
text = "operator stopped"
|
||||
else:
|
||||
text = str(reason).strip()
|
||||
if not text:
|
||||
raise ValueError("a stop reason must not be blank")
|
||||
self.campaign.stop(text)
|
||||
|
||||
# ------------------------------------------------------------------ alignment result
|
||||
|
||||
def mark_aligned(self, site_id: str | None = None) -> None:
|
||||
"""The window confirmed the requested move finished.
|
||||
|
||||
Only this call sets the "this point is aligned" flag. A request on its own
|
||||
must not, because the move can be refused, cancelled, or fail half way —
|
||||
and starting a session there would record the old pose. ``site_id=None``
|
||||
names the point currently in focus.
|
||||
"""
|
||||
target = self._focus_site_id() if site_id is None else str(site_id)
|
||||
if not target:
|
||||
raise ValueError("there is no point in focus to mark as aligned")
|
||||
self._aligned.add(target)
|
||||
|
||||
def invalidate_alignment(self) -> None:
|
||||
"""Forget every "this point is aligned" flag.
|
||||
|
||||
The flag records that the platform stood on a point's pose at some moment.
|
||||
Anything that moves it elsewhere (a jog, a home, a move from another page),
|
||||
or changes what a pose means (a new surface, a changed mounting), makes
|
||||
that statement false, and starting on it would record the wrong geometry.
|
||||
Never raises: it runs on the window's motion paths.
|
||||
"""
|
||||
self._aligned.clear()
|
||||
|
||||
def mark_alignment_failed(self, site_id: str | None = None) -> None:
|
||||
"""A move was refused, cancelled or failed, so the point stays unaligned.
|
||||
|
||||
The page still shows ``align_point`` for a retry. This never raises: it
|
||||
runs on an error path where the page has its own message to show.
|
||||
"""
|
||||
target = self._focus_site_id() if site_id is None else str(site_id)
|
||||
if target:
|
||||
self._aligned.discard(target)
|
||||
|
||||
# ------------------------------------------------------------------ queue editing
|
||||
|
||||
def add_point(
|
||||
self,
|
||||
site_id: str,
|
||||
x: float,
|
||||
y: float,
|
||||
z: float,
|
||||
normal: tuple[float, float, float],
|
||||
pose: dict,
|
||||
levels: list[float],
|
||||
hold_s: float,
|
||||
repeats: int,
|
||||
*,
|
||||
uv_px: tuple[float, float] | None = None,
|
||||
) -> None:
|
||||
"""Add a solved point to the queue. Idle only; never moves the platform."""
|
||||
self._require_queue_editable()
|
||||
self.campaign.add_solved_site(
|
||||
site_id,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
normal,
|
||||
pose,
|
||||
levels,
|
||||
hold_s,
|
||||
repeats,
|
||||
max_force_n=self._require_max_force(),
|
||||
uv_px=uv_px,
|
||||
)
|
||||
self._skipped.discard(site_id)
|
||||
self._aligned.discard(site_id)
|
||||
|
||||
def update_point(
|
||||
self,
|
||||
site_id: str,
|
||||
x: float,
|
||||
y: float,
|
||||
z: float,
|
||||
levels: list[float],
|
||||
hold_s: float,
|
||||
repeats: int,
|
||||
*,
|
||||
new_id: str | None = None,
|
||||
) -> None:
|
||||
"""Replace a point's geometry or plan. Idle only.
|
||||
|
||||
The stored pose and normal are kept by ``Campaign.update_site`` unless the
|
||||
page re-solves and stores them; a changed point therefore loses its "this
|
||||
point is aligned" flag, because that move was aimed at the old geometry.
|
||||
"""
|
||||
self._require_queue_editable()
|
||||
self.campaign.update_site(
|
||||
site_id,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
levels,
|
||||
hold_s,
|
||||
repeats,
|
||||
max_force_n=self._require_max_force(),
|
||||
new_id=new_id,
|
||||
)
|
||||
for target in {site_id, new_id or site_id}:
|
||||
self._skipped.discard(target)
|
||||
self._aligned.discard(target)
|
||||
|
||||
def remove_point(self, site_id: str) -> None:
|
||||
"""Remove one point from the queue. Idle only."""
|
||||
self._require_queue_editable()
|
||||
self.campaign.remove_site(site_id)
|
||||
self._skipped.discard(site_id)
|
||||
self._aligned.discard(site_id)
|
||||
|
||||
def move_point(self, site_id: str, delta: int) -> None:
|
||||
"""Move one point in the queue order. Idle only; positions are unchanged."""
|
||||
self._require_queue_editable()
|
||||
self.campaign.move_site(site_id, delta)
|
||||
|
||||
def set_force_rotation(self, values: Sequence[float]) -> None:
|
||||
"""Store the force-sensor-to-tool rotation used by six-axis references."""
|
||||
self._require_queue_editable()
|
||||
self.campaign.set_force_rotation(list(values))
|
||||
|
||||
def store_solved_pose(self, site_id: str, pose: dict) -> None:
|
||||
"""Store a freshly solved pose for a point. Idle only.
|
||||
|
||||
Re-solving is queue data, so it belongs behind the controller like the
|
||||
other edits. The point loses its "this point is aligned" flag: a move made
|
||||
for the old pose is not a move to this one.
|
||||
"""
|
||||
self._require_queue_editable()
|
||||
self.campaign.store_solved_pose(site_id, pose)
|
||||
self._aligned.discard(site_id)
|
||||
|
||||
def set_max_force(self, value: float | None) -> None:
|
||||
"""Update the maximum force the readiness check and queue edits use."""
|
||||
self._max_force_n = None if value is None else float(value)
|
||||
|
||||
def _require_queue_editable(self) -> None:
|
||||
if self.campaign.phase != "idle":
|
||||
raise ActionBlocked("cap.block.queue_busy")
|
||||
|
||||
def _require_max_force(self) -> float:
|
||||
if self._max_force_n is None:
|
||||
raise ActionBlocked("cap.block.no_max_force")
|
||||
return self._max_force_n
|
||||
|
||||
# ------------------------------------------------------------------ poll and views
|
||||
|
||||
def poll(self) -> dict:
|
||||
"""Advance closing evidence and report what the page should draw.
|
||||
|
||||
``campaign.poll()`` is what finalises a repeat's recording; omitting it
|
||||
would leave the page waiting on a close that never finishes.
|
||||
"""
|
||||
self.campaign.poll()
|
||||
# The planned hold time is the operator's plan, not a button to wait for: the
|
||||
# window ends when the time does. A failure here (a writer that died during
|
||||
# the hold, say) reaches the page like any other poll error.
|
||||
self.campaign.auto_complete_hold()
|
||||
self._sync_alignment_focus()
|
||||
return {
|
||||
"phase": self.campaign.phase,
|
||||
"capture_state": self.campaign.capture_state,
|
||||
"capture_reason": self.campaign.capture_reason,
|
||||
"remaining_s": float(self.campaign.remaining_s()),
|
||||
"pending_alignment_site": self.campaign.pending_alignment_site,
|
||||
}
|
||||
|
||||
def model(self) -> CaptureModel:
|
||||
"""The queue and the campaign's own progress, read-only.
|
||||
|
||||
One row carries every field the queue table and the point editor show, so
|
||||
the page never touches ``campaign.sites``. ``pose`` and ``position`` are
|
||||
copies: the page may compare them, not write through them.
|
||||
"""
|
||||
self._sync_alignment_focus()
|
||||
completion = {row["site_id"]: row for row in self.campaign.completion()}
|
||||
rows = []
|
||||
for site in self.campaign.sites:
|
||||
counts = completion.get(site.site_id) or {}
|
||||
rows.append(
|
||||
SiteRow(
|
||||
site_id=site.site_id,
|
||||
position=site.position(),
|
||||
repeats_planned=int(site.repeats),
|
||||
repeats_complete=int(counts.get("repeats_complete", 0)),
|
||||
complete=bool(counts.get("complete", False)),
|
||||
reachable=self._pose_usable(site.site_id),
|
||||
levels=tuple(float(value) for value in site.levels_n),
|
||||
hold_s=float(site.hold_s),
|
||||
uv_px=(
|
||||
None
|
||||
if site.uv_px is None
|
||||
else (float(site.uv_px[0]), float(site.uv_px[1]))
|
||||
),
|
||||
normal=(
|
||||
None
|
||||
if site.normal is None
|
||||
else tuple(float(value) for value in site.normal)
|
||||
),
|
||||
pose=None if site.pose is None else dict(site.pose),
|
||||
position_frame=str(site.position_frame or ""),
|
||||
skipped=site.site_id in self._skipped,
|
||||
)
|
||||
)
|
||||
return CaptureModel(
|
||||
sites=rows,
|
||||
current_index=self._current_index(),
|
||||
progress=self.campaign.progress(),
|
||||
)
|
||||
|
||||
@property
|
||||
def pending_alignment(self) -> AlignmentRequest | None:
|
||||
"""The move the page still has to plan. The page clears it after taking it."""
|
||||
return self._pending_alignment
|
||||
|
||||
@pending_alignment.setter
|
||||
def pending_alignment(self, value: AlignmentRequest | None) -> None:
|
||||
self._pending_alignment = value
|
||||
|
||||
# ------------------------------------------------------------------ state helpers
|
||||
|
||||
def _closing(self) -> bool:
|
||||
return (
|
||||
self.campaign.phase == "closing" or self.campaign.capture_state == "closing"
|
||||
)
|
||||
|
||||
def _recording(self) -> bool:
|
||||
return self.campaign.capture_state == "recording" and self.campaign.phase in {
|
||||
"load",
|
||||
"hold",
|
||||
}
|
||||
|
||||
def _is_aligned(self, site_id: str) -> bool:
|
||||
return bool(site_id) and site_id in self._aligned
|
||||
|
||||
def _aligned_in_focus(self) -> bool:
|
||||
return self._is_aligned(self._focus_site)
|
||||
|
||||
def _pose_usable(self, site_id: str) -> bool:
|
||||
"""Whether the stored pose may be commanded. Read-only; never solves."""
|
||||
try:
|
||||
self.campaign.command_targets(site_id)
|
||||
except (KeyError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _sync_alignment_focus(self) -> str:
|
||||
"""Track the point in focus; a *different* point is not aligned yet.
|
||||
|
||||
An empty focus (no session, or a finished one) never clears a flag, so the
|
||||
point a ``campaign.reacquire()`` comes back to keeps its alignment.
|
||||
"""
|
||||
focus = self._focus_site_id()
|
||||
if focus and self._focus_site and focus != self._focus_site:
|
||||
self._aligned.discard(focus)
|
||||
self._focus_site = focus
|
||||
return focus
|
||||
|
||||
def _focus_site_id(self) -> str:
|
||||
campaign = self.campaign
|
||||
if not campaign.sites:
|
||||
return ""
|
||||
phase = campaign.phase
|
||||
if phase == "idle":
|
||||
return campaign.sites[0].site_id
|
||||
if phase == "liftoff":
|
||||
return campaign.pending_alignment_site
|
||||
if phase in {"done", "aborted"}:
|
||||
return ""
|
||||
return str(campaign.progress().get("site_id") or "")
|
||||
|
||||
def _current_index(self) -> int:
|
||||
"""Zero-based row the page should highlight; ``-1`` when there is none.
|
||||
|
||||
During ``liftoff`` the campaign has already advanced, so this is the point
|
||||
the operator is about to align.
|
||||
"""
|
||||
sites = self.campaign.sites
|
||||
if not sites:
|
||||
return -1
|
||||
phase = self.campaign.phase
|
||||
if phase == "idle":
|
||||
return 0
|
||||
if phase in {"done", "aborted"}:
|
||||
return -1
|
||||
index = int(self.campaign.progress().get("site_index") or 0) - 1
|
||||
return index if 0 <= index < len(sites) else -1
|
||||
@@ -156,6 +156,7 @@ def capture_verdict(
|
||||
timeout_s: float = CLOSING_TIMEOUT_S,
|
||||
probe_video: bool = True,
|
||||
source_stopped: bool = False,
|
||||
video_expected: bool = True,
|
||||
) -> CaptureVerdict:
|
||||
"""Decide whether the closed recording is evidence, and say why not.
|
||||
|
||||
@@ -163,6 +164,13 @@ def capture_verdict(
|
||||
keep polling instead of blocking its event loop. Both recording paths pass
|
||||
their own abort reason: the operator's reason is more useful than a generic
|
||||
token and is what the existing single-trial contract already records.
|
||||
|
||||
``video_expected=False`` is the operator's recorded choice not to keep a
|
||||
video. The video-specific judgements (a missing frame, an unexpected segment
|
||||
count, dropped video frames, an undecodable file) are then skipped, because
|
||||
there is nothing to find; the data rows must still have been written and
|
||||
dropped data still keeps the evidence incomplete. The default keeps every
|
||||
existing caller's meaning: a video was expected.
|
||||
"""
|
||||
if recorder is None:
|
||||
# Nothing was recorded, so nothing is evidence. A simulation and a
|
||||
@@ -208,15 +216,16 @@ def capture_verdict(
|
||||
return verdict(INCOMPLETE, reason or ABORTED)
|
||||
if stats.frames <= 0:
|
||||
return verdict(INCOMPLETE, ZERO_FRAMES)
|
||||
if stats.video_frames <= 0:
|
||||
return verdict(INCOMPLETE, VIDEO_MISSING)
|
||||
if stats.segments != 1:
|
||||
return verdict(INCOMPLETE, SEGMENT_COUNT)
|
||||
if video_expected:
|
||||
if stats.video_frames <= 0:
|
||||
return verdict(INCOMPLETE, VIDEO_MISSING)
|
||||
if stats.segments != 1:
|
||||
return verdict(INCOMPLETE, SEGMENT_COUNT)
|
||||
if stats.dropped_data:
|
||||
return verdict(INCOMPLETE, DROPPED_DATA)
|
||||
if stats.dropped_video:
|
||||
if video_expected and stats.dropped_video:
|
||||
return verdict(INCOMPLETE, DROPPED_VIDEO)
|
||||
if probe_video:
|
||||
if video_expected and probe_video:
|
||||
ok, detail = probe_segments(report.directory)
|
||||
if not ok:
|
||||
payload = dict(base["report"] or {})
|
||||
|
||||
@@ -18,10 +18,13 @@ from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import statistics
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -38,6 +41,11 @@ from .capture_guard import (
|
||||
capture_verdict,
|
||||
)
|
||||
from .frames_index import read_frame_index, unique_frames
|
||||
from .recording_options import (
|
||||
RecordingOptions,
|
||||
make_record_config,
|
||||
require_codec_available,
|
||||
)
|
||||
from .sensor.frames import PLATFORM_AXES, TOOL_AXIS
|
||||
|
||||
SENSOR_MODEL = "finger"
|
||||
@@ -408,6 +416,9 @@ class Campaign:
|
||||
self.sensor_model = ""
|
||||
self.sensor_sn = ""
|
||||
self.simulate = False
|
||||
#: The recording choices this session was established with (plan P2).
|
||||
#: Written to the root manifest once; every repeat reuses them.
|
||||
self.recording = RecordingOptions()
|
||||
self.reason = ""
|
||||
self._document: dict | None = None
|
||||
self._site_index = 0
|
||||
@@ -614,6 +625,8 @@ class Campaign:
|
||||
max_force_n: float,
|
||||
simulate: bool,
|
||||
min_force_n: float = 0.0,
|
||||
recording: RecordingOptions | None = None,
|
||||
codec_availability: Mapping[str, bool] | None = None,
|
||||
) -> Path:
|
||||
if self.is_active or self.capture_state == "closing" or self.phase == "closing":
|
||||
raise RuntimeError("a multi-point acquisition is already running")
|
||||
@@ -633,12 +646,19 @@ class Campaign:
|
||||
raise ValueError("maximum force must be a positive number you enter")
|
||||
for site in self.sites:
|
||||
load_steps(list(site.levels_n), force, min_force_n=lower)
|
||||
# Refuse an encoder this machine cannot open before anything is written:
|
||||
# the session block must describe the recording that would actually run.
|
||||
options = RecordingOptions() if recording is None else recording
|
||||
if not isinstance(options, RecordingOptions):
|
||||
raise TypeError("recording must be a RecordingOptions instance")
|
||||
require_codec_available(options, codec_availability)
|
||||
self.coordinate_frame = coordinate_frame.strip()
|
||||
self.max_force_n = force
|
||||
self.min_force_n = lower
|
||||
self.sensor_model = sensor_model.strip()
|
||||
self.sensor_sn = sensor_sn.strip()
|
||||
self.simulate = bool(simulate)
|
||||
self.recording = options
|
||||
self.reason = ""
|
||||
self._single_site = False
|
||||
self._finished_step_pending = False
|
||||
@@ -685,6 +705,7 @@ class Campaign:
|
||||
"automatic_force_control": False,
|
||||
"platform_motion": False,
|
||||
"simulate": self.simulate,
|
||||
"recording": self.recording.as_block(),
|
||||
"sync": SYNC,
|
||||
"sites": [self._site_record(site) for site in self.sites],
|
||||
"trials": [],
|
||||
@@ -786,6 +807,34 @@ class Campaign:
|
||||
self._finished_step_pending = False
|
||||
self._begin_close(complete=True)
|
||||
|
||||
def auto_complete_hold(self) -> bool:
|
||||
"""Close the hold once its planned time has run out; ``True`` if it closed.
|
||||
|
||||
The window ends at the planned time (``start + hold``), not at whenever the
|
||||
page happened to notice: the reference window is the plan, so the recorded
|
||||
length must not depend on how late a timer tick or a click was.
|
||||
"""
|
||||
if self.phase != "hold" or self._active is None or self._hold_started_ns is None:
|
||||
return False
|
||||
if self.remaining_s() > 0:
|
||||
return False
|
||||
self.complete_hold(now_ns=self._hold_started_ns + int(self._hold_s * 1e9))
|
||||
return True
|
||||
|
||||
def end_session(self) -> None:
|
||||
"""Hand a finished or stopped session back to the idle phase.
|
||||
|
||||
The recorded evidence is untouched: the directory, the session document and
|
||||
the per-point counts stay readable, and only the phase changes so the queue
|
||||
can be edited and the next session started. A repeat that is still being
|
||||
written is refused, because it is not part of the evidence yet.
|
||||
"""
|
||||
if self.phase not in {"done", "aborted"}:
|
||||
raise RuntimeError("only a finished or stopped session can be closed out")
|
||||
if self.capture_state == "closing":
|
||||
raise RuntimeError("the last repeat is still being written")
|
||||
self.phase = "idle"
|
||||
|
||||
def confirm_liftoff(self) -> None:
|
||||
if self.phase != "liftoff":
|
||||
raise RuntimeError("liftoff confirmation is only required between points")
|
||||
@@ -911,6 +960,81 @@ class Campaign:
|
||||
self._open_repeat()
|
||||
self.phase = "load"
|
||||
|
||||
def skip_current_point(self) -> None:
|
||||
"""Leave the open point unrecorded and move on to the next one.
|
||||
|
||||
A repeat directory and its trial record exist from the moment the repeat
|
||||
opens, so skipping removes that unfinished record instead of inventing a
|
||||
trial status the format does not define. The point stays in ``sites`` and
|
||||
in the session document, so a later ``reacquire`` can still record it.
|
||||
"""
|
||||
if self.phase not in {"load", "hold"} or self.capture_state == "closing":
|
||||
raise RuntimeError("no point is open to skip")
|
||||
if self._recorder is not None:
|
||||
# The capture writer owns its directory; stop the recording first.
|
||||
raise RuntimeError("stop the recording before skipping the point")
|
||||
site = self.sites[self._site_index]
|
||||
directory, entry = self._trial_dir, self._trial_entry
|
||||
# The files must be closed before the folder can be moved (Windows refuses
|
||||
# otherwise), but nothing about the campaign changes until the folder is
|
||||
# out of the way: a refusal leaves the repeat exactly as it was.
|
||||
self._close_repeat_files()
|
||||
completed = 0
|
||||
reference = None if directory is None else directory / "reference.csv"
|
||||
if reference is not None and reference.is_file():
|
||||
with reference.open(encoding="utf-8-sig", newline="") as handle:
|
||||
completed = sum(
|
||||
1 for row in csv.DictReader(handle) if row.get("hold_complete") == "1"
|
||||
)
|
||||
aside = None
|
||||
if directory is not None and directory.exists():
|
||||
# One rename is all-or-nothing; a recursive delete that fails half way
|
||||
# would leave a reference file with its rows gone.
|
||||
aside = self.directory / f".skipped-{directory.parent.name}-{directory.name}"
|
||||
while aside.exists():
|
||||
aside = aside.with_name(f"{aside.name}-{uuid4().hex[:6]}")
|
||||
try:
|
||||
directory.replace(aside)
|
||||
except OSError as exc:
|
||||
self._trial_dir, self._trial_entry = directory, entry
|
||||
self._reopen_repeat_files(directory)
|
||||
raise RuntimeError(
|
||||
f"the unfinished repeat folder could not be set aside ({exc}); "
|
||||
"close anything that has it open and try again"
|
||||
) from exc
|
||||
self._active = None
|
||||
self._pending = None
|
||||
self._hold_started_ns = None
|
||||
self._finished_step_pending = False
|
||||
self._completed_holds = max(0, self._completed_holds - completed)
|
||||
if entry is not None:
|
||||
self._document["trials"].remove(entry)
|
||||
if aside is not None:
|
||||
try:
|
||||
shutil.rmtree(aside)
|
||||
except OSError as exc:
|
||||
# The skip itself has happened; a leftover hidden folder at the
|
||||
# session root cannot collide with a repeat directory.
|
||||
logging.getLogger(__name__).warning(
|
||||
"Skipped repeat left at %s: %s", aside, exc
|
||||
)
|
||||
self._event(
|
||||
"site_skipped",
|
||||
{"site_id": site.site_id, "repeat_index": self._repeat_index + 1},
|
||||
)
|
||||
self._site_index += 1
|
||||
self._repeat_index = 0
|
||||
if self._site_index < len(self.sites):
|
||||
self.phase = "liftoff"
|
||||
self._event(
|
||||
"liftoff_required",
|
||||
{"next_site_id": self.sites[self._site_index].site_id},
|
||||
)
|
||||
self._save()
|
||||
return
|
||||
self._finish_session(self._session_status())
|
||||
self.phase = "done"
|
||||
|
||||
def completion(self) -> list[dict]:
|
||||
counts: dict[str, int] = {}
|
||||
if self._document is not None:
|
||||
@@ -1212,6 +1336,7 @@ class Campaign:
|
||||
guard=self._closing_guard,
|
||||
elapsed_s=elapsed,
|
||||
source_stopped=self._source_stopped,
|
||||
video_expected=self.recording.video,
|
||||
)
|
||||
if verdict.status == PENDING:
|
||||
self.capture_state = "closing"
|
||||
@@ -1422,6 +1547,13 @@ class Campaign:
|
||||
return "partial"
|
||||
return "incomplete"
|
||||
|
||||
def _reopen_repeat_files(self, directory: Path) -> None:
|
||||
"""Reattach the open repeat's writers after a skip that did not happen."""
|
||||
self._reopen_reference()
|
||||
handle = (directory / "events.csv").open("a", newline="", encoding="utf-8")
|
||||
self._events_file = handle
|
||||
self._events = csv.writer(handle)
|
||||
|
||||
def _reopen_events(self) -> None:
|
||||
if self._session_events is not None:
|
||||
return
|
||||
@@ -1470,11 +1602,9 @@ class Campaign:
|
||||
raise RuntimeError("start the camera and wait for a frame before live capture")
|
||||
if self._recorder is not None:
|
||||
raise RuntimeError("a capture is already running for this repeat")
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||
from orisys_app_shared.record import ExperimentRecorder
|
||||
|
||||
recorder = ExperimentRecorder(
|
||||
RecordConfig(fps=self._fps, segment_seconds=24 * 3600.0, preserve_all_frames=True)
|
||||
)
|
||||
recorder = ExperimentRecorder(make_record_config(self.recording, self._fps))
|
||||
recorder.start(
|
||||
root=self._trial_dir,
|
||||
session_id=int(packet.session_id),
|
||||
|
||||
@@ -20,6 +20,11 @@ from .motion.service import (
|
||||
clamp_bas_task,
|
||||
)
|
||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||
from .recording_options import (
|
||||
DEFAULT_CODEC,
|
||||
DEFAULT_LOSSLESS_FRAMES,
|
||||
DEFAULT_VIDEO,
|
||||
)
|
||||
|
||||
#: Connection modes, in the order the combo offers them.
|
||||
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
|
||||
@@ -176,6 +181,20 @@ def _default_axis_params() -> dict[str, dict[str, float | int]]:
|
||||
}
|
||||
|
||||
|
||||
def _default_recording() -> dict[str, Any]:
|
||||
"""Plan P2: what a capture records.
|
||||
|
||||
The bench default stays ``mp4v`` until the P2b equivalence report is accepted
|
||||
(decision D2); video is captured by default because it is the evidence the
|
||||
review and the video page read.
|
||||
"""
|
||||
return {
|
||||
"video": DEFAULT_VIDEO,
|
||||
"codec": DEFAULT_CODEC,
|
||||
"lossless_frames": DEFAULT_LOSSLESS_FRAMES,
|
||||
}
|
||||
|
||||
|
||||
def default_preferences() -> dict[str, Any]:
|
||||
"""AutoCal's default preference document."""
|
||||
return {
|
||||
@@ -227,6 +246,8 @@ def default_preferences() -> dict[str, Any]:
|
||||
"sensor_setup": _default_sensor_setup(),
|
||||
#: P5: grid generation and run parameters.
|
||||
"sequence_profile": _default_sequence_profile(),
|
||||
#: P2: video on/off, codec and lossless frames for a new capture session.
|
||||
"recording": _default_recording(),
|
||||
#: The press axis's mechanical zero and sign, so a solved contact height
|
||||
#: can become a controller target. Uncalibrated means the press axis is
|
||||
#: held rather than driven.
|
||||
@@ -277,6 +298,7 @@ _MERGED_BLOCKS = (
|
||||
"safety_limits",
|
||||
"sensor_setup",
|
||||
"sequence_profile",
|
||||
"recording",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""What a recording captures, and how those settings reach the shared recorder.
|
||||
|
||||
The options are a small, explicit block rather than loose arguments for two
|
||||
reasons. They are written into the session manifest once, so a later reader can
|
||||
tell whether a missing video was a failure or the operator's choice. And they are
|
||||
checked against this machine's encoders **before** recording starts: a codec that
|
||||
cannot be opened is refused, never silently replaced by another one.
|
||||
|
||||
Defaults are the confirmed bench decision (plan D2): video on, ``mp4v``, lossless
|
||||
frames off. ``mp4v`` measured far more bitrate than H.264, but switching the
|
||||
default is a separate decision that needs the equivalence report, so this module
|
||||
keeps the current default until then.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
#: Default recording settings. These constants are the single definition; the
|
||||
#: preference schema re-exports them instead of spelling the values again.
|
||||
DEFAULT_VIDEO = True
|
||||
DEFAULT_CODEC = "mp4v"
|
||||
DEFAULT_LOSSLESS_FRAMES = False
|
||||
|
||||
#: Codecs the startup probe writes a test clip for, in the order the UI offers them.
|
||||
CODEC_CANDIDATES: tuple[str, ...] = ("mp4v", "avc1", "H264")
|
||||
|
||||
#: Probe clip shape: small and short, but a real encode and a real decode.
|
||||
PROBE_DIRECTORY = "codec_probe"
|
||||
PROBE_FRAMES = 3
|
||||
PROBE_WIDTH = 64
|
||||
PROBE_HEIGHT = 48
|
||||
PROBE_FPS = 10.0
|
||||
|
||||
#: One continuous recording per trial, as the shared recorder already writes it.
|
||||
TRIAL_SEGMENT_SECONDS = 24 * 3600.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecordingOptions:
|
||||
"""The recording settings one session was established with."""
|
||||
|
||||
video: bool = DEFAULT_VIDEO
|
||||
codec: str = DEFAULT_CODEC
|
||||
lossless_frames: bool = DEFAULT_LOSSLESS_FRAMES
|
||||
|
||||
def as_block(self) -> dict[str, object]:
|
||||
"""The ``recording`` block frozen for ``session.json``."""
|
||||
return {
|
||||
"video": bool(self.video),
|
||||
"codec": self.codec,
|
||||
"lossless_frames": bool(self.lossless_frames),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_block(cls, block: object | None) -> "RecordingOptions":
|
||||
"""Read a stored block; a missing or malformed value keeps its default.
|
||||
|
||||
A session recorded before the block existed has no answer to "was the
|
||||
video expected?". Its recorder always wrote video, so the absent block
|
||||
means video was expected, which is the default here.
|
||||
"""
|
||||
if not isinstance(block, dict):
|
||||
return cls()
|
||||
video = block.get("video")
|
||||
codec = block.get("codec")
|
||||
lossless = block.get("lossless_frames")
|
||||
return cls(
|
||||
video=video if isinstance(video, bool) else DEFAULT_VIDEO,
|
||||
codec=codec.strip() if isinstance(codec, str) and codec.strip() else DEFAULT_CODEC,
|
||||
lossless_frames=(
|
||||
lossless if isinstance(lossless, bool) else DEFAULT_LOSSLESS_FRAMES
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def make_record_config(options: RecordingOptions, fps: float):
|
||||
"""The shared recorder's config for one trial under these options.
|
||||
|
||||
The import stays inside the function so this module (and the preference
|
||||
schema that reads its defaults) does not pull in the recorder's NumPy and
|
||||
OpenCV dependencies at start-up.
|
||||
"""
|
||||
from orisys_app_shared.record import RecordConfig
|
||||
|
||||
return RecordConfig(
|
||||
fps=float(fps),
|
||||
segment_seconds=TRIAL_SEGMENT_SECONDS,
|
||||
preserve_all_frames=True,
|
||||
video=bool(options.video),
|
||||
codec=str(options.codec),
|
||||
lossless_frames=bool(options.lossless_frames),
|
||||
)
|
||||
|
||||
|
||||
def default_options(prefs: Mapping[str, Any] | None) -> RecordingOptions:
|
||||
"""The recording settings stored under ``prefs["recording"]``, defaults filled.
|
||||
|
||||
Every key falls back independently, so a block saved by an older build (one
|
||||
that did not have the key yet) is not read as an absent recording choice.
|
||||
"""
|
||||
block = prefs.get("recording") if isinstance(prefs, Mapping) else None
|
||||
return RecordingOptions.from_block(block)
|
||||
|
||||
|
||||
def probe_codecs(scratch: Path) -> dict[str, bool]:
|
||||
"""Encode a few frames with each candidate codec and report which ones work.
|
||||
|
||||
The probe writes real clips into ``scratch`` and reads one frame back, so a
|
||||
writer that opens but produces an empty file is not called available. Probe
|
||||
files are removed afterwards; the caller keeps only the result. Nothing is
|
||||
substituted when a codec fails: the caller disables it and says so.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
directory = Path(scratch) / PROBE_DIRECTORY
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
frame = np.zeros((PROBE_HEIGHT, PROBE_WIDTH, 3), dtype=np.uint8)
|
||||
frame[:, :, 1] = 128
|
||||
availability: dict[str, bool] = {}
|
||||
for codec in CODEC_CANDIDATES:
|
||||
target = directory / f"{codec}.mp4"
|
||||
available = False
|
||||
try:
|
||||
writer = cv2.VideoWriter(
|
||||
str(target),
|
||||
cv2.VideoWriter_fourcc(*codec),
|
||||
PROBE_FPS,
|
||||
(PROBE_WIDTH, PROBE_HEIGHT),
|
||||
)
|
||||
try:
|
||||
if writer.isOpened():
|
||||
for _ in range(PROBE_FRAMES):
|
||||
writer.write(frame)
|
||||
finally:
|
||||
writer.release()
|
||||
available = _probe_reads_back(target)
|
||||
except Exception: # noqa: BLE001 - an encoder failure is a result, not a crash
|
||||
available = False
|
||||
finally:
|
||||
try:
|
||||
target.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
availability[codec] = available
|
||||
try:
|
||||
directory.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return availability
|
||||
|
||||
|
||||
def _probe_reads_back(path: Path) -> bool:
|
||||
"""A written probe is only available when an encoded frame can be decoded."""
|
||||
import cv2
|
||||
|
||||
if not path.is_file() or path.stat().st_size <= 0:
|
||||
return False
|
||||
capture = cv2.VideoCapture(str(path))
|
||||
try:
|
||||
return bool(capture.isOpened() and capture.read()[0])
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
|
||||
def codec_is_available(options: RecordingOptions, availability: Mapping[str, bool] | None) -> bool:
|
||||
"""Whether these options can be recorded given a probe result.
|
||||
|
||||
``None`` means nothing was probed (a headless caller that is not the UI), and
|
||||
the answer is left to the recorder rather than guessing.
|
||||
"""
|
||||
if not options.video:
|
||||
return True
|
||||
if availability is None:
|
||||
return True
|
||||
return availability.get(options.codec) is True
|
||||
|
||||
|
||||
def require_codec_available(
|
||||
options: RecordingOptions, availability: Mapping[str, bool] | None
|
||||
) -> None:
|
||||
"""Refuse to start with an encoder this machine cannot open.
|
||||
|
||||
There is deliberately no fallback to another codec: a recording made under a
|
||||
silently different codec is not the recording the session block will claim.
|
||||
"""
|
||||
if codec_is_available(options, availability):
|
||||
return
|
||||
raise ValueError(
|
||||
f"the selected video codec is not available on this machine: {options.codec}"
|
||||
)
|
||||
@@ -15,6 +15,7 @@ import numpy as np
|
||||
|
||||
from .calibration_session import write_json
|
||||
from .frames_index import read_frame_index, unique_frames
|
||||
from .recording_options import RecordingOptions
|
||||
|
||||
|
||||
def _rows(path: Path) -> list[dict]:
|
||||
@@ -133,15 +134,22 @@ def _sha256(path: Path) -> str:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
|
||||
def _capture_source(
|
||||
directory: Path, root: Path, inputs: dict, *, video_expected: bool = True
|
||||
) -> dict:
|
||||
"""Read the frame source recorded beside one repeat, or report why it is unusable.
|
||||
|
||||
Image-derived force samples only exist where the shared recorder ran: simulation and
|
||||
a detached camera leave no frames, and the gap has to stay visible as a reason rather
|
||||
than become an empty window that reads like a measurement.
|
||||
|
||||
``video_expected=False`` is a session recorded without video on purpose: the frames
|
||||
were still acquired (a row carries its host time), only none was written to a video.
|
||||
Frames are then accepted regardless of video membership and no video file is read.
|
||||
A session with no recording block keeps the default, which requires the video.
|
||||
"""
|
||||
source = {
|
||||
"recorded": False, "reason": "no_capture_directory",
|
||||
"recorded": False, "reason": "no_capture_directory", "video_expected": video_expected,
|
||||
"directory": str(directory.relative_to(root)) if directory.is_relative_to(root) else str(directory),
|
||||
"capture_session_id": None, "frames": 0, "in_video": 0, "time_bases": [],
|
||||
"segments": [], "samples": [],
|
||||
@@ -166,22 +174,26 @@ def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
|
||||
# left out instead of being paired with an order the files cannot prove.
|
||||
source["duplicate_frame_ids"] = len(duplicated)
|
||||
for frame in frame_rows:
|
||||
if frame.dropped or frame.t_host_mono_ns is None or frame.in_video is False:
|
||||
if frame.dropped or frame.t_host_mono_ns is None:
|
||||
continue
|
||||
if frame.frame_id in duplicated:
|
||||
continue
|
||||
segment = frame.segment
|
||||
video = directory / f"raw_{segment:04d}.mp4"
|
||||
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
|
||||
source["reason"] = "missing_video_segment"
|
||||
return source
|
||||
if video_expected:
|
||||
if frame.in_video is False:
|
||||
continue
|
||||
segment = frame.segment
|
||||
video = directory / f"raw_{segment:04d}.mp4"
|
||||
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
|
||||
source["reason"] = "missing_video_segment"
|
||||
return source
|
||||
segments.add(segment)
|
||||
in_video += 1
|
||||
frames[frame.frame_id] = frame.t_host_mono_ns
|
||||
bases.add(frame.time_basis)
|
||||
in_video += 1
|
||||
if segment:
|
||||
segments.add(segment)
|
||||
if not frames:
|
||||
source["reason"] = "no_recorded_video_frames"
|
||||
source["reason"] = (
|
||||
"no_recorded_video_frames" if video_expected else "no_recorded_frames"
|
||||
)
|
||||
return source
|
||||
for segment in segments:
|
||||
video = directory / f"raw_{segment:04d}.mp4"
|
||||
@@ -256,6 +268,10 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
|
||||
if not math.isfinite(maximum) or maximum <= 0:
|
||||
raise ValueError("invalid session force range")
|
||||
simulated = manifest.get("simulate") is True
|
||||
# The recording block decides whether a missing video is a failure. A session
|
||||
# recorded before the block existed keeps the default, which expects video.
|
||||
recording = RecordingOptions.from_block(manifest.get("recording"))
|
||||
video_expected = recording.video
|
||||
for trial in manifest["trials"]:
|
||||
trial_id = trial["trial_id"]
|
||||
path = (root / trial["directory"]).resolve()
|
||||
@@ -270,7 +286,9 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
|
||||
if not complete:
|
||||
issues.append({"trial_id": trial_id, "reason": "incomplete_trial",
|
||||
"detail": str(trial.get("reason") or record.get("status") or "")})
|
||||
source = _capture_source(path / CAPTURE_DIRECTORY, root, inputs)
|
||||
source = _capture_source(
|
||||
path / CAPTURE_DIRECTORY, root, inputs, video_expected=video_expected
|
||||
)
|
||||
if source.get("duplicate_frame_ids"):
|
||||
issues.append({
|
||||
"trial_id": trial_id, "reason": "duplicate_frame_identity",
|
||||
@@ -391,6 +409,7 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N
|
||||
"formal_conclusion": False, "sites": sites, "issues": issues,
|
||||
"selections": selections, "input_sha256": inputs, "analysis_inputs": {},
|
||||
"trials": manifest["trials"], "coverage": coverage,
|
||||
"recording": recording.as_block(),
|
||||
"uv_definition": manifest.get("uv_definition"),
|
||||
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
|
||||
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
|
||||
@@ -525,7 +544,9 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
|
||||
"source_directory": str(root), "session_status": manifest["status"],
|
||||
"status": "exploratory", "sites": sites, "issues": issues,
|
||||
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
|
||||
"trials": manifest["trials"], "uv_definition": manifest["uv_definition"],
|
||||
"trials": manifest["trials"],
|
||||
"recording": RecordingOptions.from_block(manifest.get("recording")).as_block(),
|
||||
"uv_definition": manifest["uv_definition"],
|
||||
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
|
||||
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ from .frames_index import (
|
||||
reference_holds,
|
||||
resolve_capture_directory,
|
||||
)
|
||||
from .recording_options import RecordingOptions
|
||||
from .session_review import review_session
|
||||
|
||||
SCHEMA_VERSION = "autocal-summary-1.0"
|
||||
@@ -242,6 +243,14 @@ def build_windows(root: Path, review: dict) -> list[dict]:
|
||||
windows_by_trial.setdefault(window["trial_id"], []).append(window)
|
||||
cache: dict = {}
|
||||
rows: list[dict] = []
|
||||
# A multi-point recording has to join a hold to a frame that reached the
|
||||
# video. A session recorded without video (the root manifest's ``recording``
|
||||
# block) has no video membership to require; every recorded frame carries
|
||||
# its own host time. A session with no block is treated as video-expected.
|
||||
require_video = (
|
||||
bool(review.get("source_format"))
|
||||
and RecordingOptions.from_block(review.get("recording")).video
|
||||
)
|
||||
for trial in review["trials"]:
|
||||
trial_id = trial["trial_id"]
|
||||
trial_status = str(trial.get("status") or "")
|
||||
@@ -261,7 +270,6 @@ def build_windows(root: Path, review: dict) -> list[dict]:
|
||||
if window is not None:
|
||||
matched.add((hold["step_index"], hold["t_start_host_mono_ns"]))
|
||||
directory = _capture_directory(root, trial, window or {})
|
||||
require_video = bool(review.get("source_format"))
|
||||
if window is not None and hold["complete"]:
|
||||
start, end, method = _window_interval(window, hold)
|
||||
if directory is None:
|
||||
|
||||
@@ -6,6 +6,11 @@ it wrote, renames that directory into place, and only then replaces
|
||||
before the rename leaves the previous pointer untouched; the acquisition state is
|
||||
never rolled back because an export failed.
|
||||
|
||||
After the pointer moves, the same four files are mirrored into ``summary/`` at
|
||||
the session root so the operator can open one obvious folder. The mirror is a
|
||||
convenience copy: the pointer remains the only authority, and a failed mirror is
|
||||
reported without moving the pointer back or changing the recording.
|
||||
|
||||
Nothing is published when the recorded inputs changed since the summary was
|
||||
built: the numbers would no longer be reproducible from the files on disk.
|
||||
"""
|
||||
@@ -16,17 +21,30 @@ import csv
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from .calibration_session import write_json
|
||||
from .session_review import review_session
|
||||
from .summary import SCHEMA_VERSION, build_summary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POINTER_NAME = "summary-latest.json"
|
||||
POINTER_FORMAT = "autocal-summary-pointer-1.0"
|
||||
EXPORT_FORMAT = "autocal-summary-export-1.0"
|
||||
#: Where the readable copy of the last published summary lives, under the session root.
|
||||
SUMMARY_DIR_NAME = "summary"
|
||||
SUMMARY_FILES = (
|
||||
"summary.json",
|
||||
"summary.html",
|
||||
"baseline_load_data.csv",
|
||||
"sensitivity_values.csv",
|
||||
)
|
||||
|
||||
BASELINE_HEADER = (
|
||||
"schema_version", "summary_id", "session_id", "trial_id", "hold_id", "site_id",
|
||||
@@ -260,7 +278,7 @@ def _write_export(directory: Path, summary: dict) -> dict[str, str]:
|
||||
_write_csv(directory / "baseline_load_data.csv", BASELINE_HEADER, summary.get("windows") or [])
|
||||
_write_csv(directory / "sensitivity_values.csv", SENSITIVITY_HEADER, summary.get("sensitivity") or [])
|
||||
hashes = {}
|
||||
for name in ("summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"):
|
||||
for name in SUMMARY_FILES:
|
||||
path = directory / name
|
||||
if not path.is_file():
|
||||
raise SummaryExportError(f"the export is missing {name}")
|
||||
@@ -294,6 +312,105 @@ def _read_pointer(root: Path) -> dict:
|
||||
return pointer if isinstance(pointer, dict) else {}
|
||||
|
||||
|
||||
def _recorded_summary_id(summary_json: Path) -> str:
|
||||
"""The ``summary_id`` inside an exported summary, or ``""`` when unreadable."""
|
||||
try:
|
||||
payload = json.loads(summary_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return str(payload.get("summary_id") or "") if isinstance(payload, dict) else ""
|
||||
|
||||
|
||||
def published_summary_directory(root: Path) -> Path | None:
|
||||
"""The folder an operator should open for the last published summary.
|
||||
|
||||
The readable ``summary/`` mirror, but only while it is the version the pointer
|
||||
names: a failed mirror leaves the previous copy in place, and opening that
|
||||
would show numbers the pointer no longer claims. Otherwise the directory the
|
||||
pointer itself names, which is the authority. ``None`` means nothing has been
|
||||
published yet; a caller should disable the action rather than invent a path.
|
||||
"""
|
||||
root = Path(root).resolve()
|
||||
pointer = _read_pointer(root)
|
||||
pointer_id = str(pointer.get("summary_id") or "") if pointer else ""
|
||||
flat = root / SUMMARY_DIR_NAME
|
||||
flat_summary = flat / "summary.json"
|
||||
if flat.is_dir() and flat_summary.is_file():
|
||||
if not pointer_id or _recorded_summary_id(flat_summary) == pointer_id:
|
||||
return flat
|
||||
if not pointer:
|
||||
return None
|
||||
path = root / str(pointer.get("path") or "")
|
||||
return path if path.is_dir() else None
|
||||
|
||||
|
||||
def _write_flat_summary(temporary: Path, source: Path, hashes: dict[str, str]) -> None:
|
||||
"""Copy the published four files into a fresh directory and verify them.
|
||||
|
||||
The copy is written into ``temporary`` first, so a failure here never touches
|
||||
the existing ``summary/``; it becomes the new readable copy only after every
|
||||
byte matched the pointer's hashes.
|
||||
"""
|
||||
temporary.mkdir(parents=True, exist_ok=False)
|
||||
for name in SUMMARY_FILES:
|
||||
destination = temporary / name
|
||||
shutil.copy2(source / name, destination)
|
||||
if hashes.get(name) and _sha256_file(destination) != hashes[name]:
|
||||
raise SummaryExportError(f"the summary copy of {name} does not match the export")
|
||||
|
||||
|
||||
def _replace_directory(target: Path, temporary: Path) -> None:
|
||||
"""Swap ``temporary`` into ``target``, restoring the old directory on failure."""
|
||||
backup = target.parent / f".tmp-summary-old-{uuid4().hex[:8]}"
|
||||
moved_old = False
|
||||
if target.exists():
|
||||
target.rename(backup)
|
||||
moved_old = True
|
||||
try:
|
||||
temporary.rename(target)
|
||||
except OSError:
|
||||
if moved_old:
|
||||
backup.rename(target)
|
||||
raise
|
||||
if moved_old:
|
||||
try:
|
||||
shutil.rmtree(backup)
|
||||
except OSError:
|
||||
# The new copy is in place; a leftover backup is untidy, not a failure.
|
||||
logger.warning("Could not remove the previous summary copy: %s", backup)
|
||||
|
||||
|
||||
def _publish_flat_copy(
|
||||
root: Path,
|
||||
source: Path,
|
||||
hashes: dict[str, str],
|
||||
on_warning: Callable[[str], None] | None,
|
||||
) -> None:
|
||||
"""Mirror the published summary under ``summary/``; report, never fail the export.
|
||||
|
||||
The pointer has already moved when this runs. The readable copy is a
|
||||
convenience, so a failure here leaves the previous ``summary/`` and the
|
||||
pointer exactly as they were and only tells the operator.
|
||||
"""
|
||||
temporary = root / f".tmp-summary-flat-{uuid4().hex[:8]}"
|
||||
try:
|
||||
_write_flat_summary(temporary, source, hashes)
|
||||
_replace_directory(root / SUMMARY_DIR_NAME, temporary)
|
||||
except Exception as exc: # noqa: BLE001 - a copy failure is reported, not fatal
|
||||
if temporary.exists():
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
message = (
|
||||
f"the readable copy under {SUMMARY_DIR_NAME}/ was not written: {exc}. "
|
||||
"The pointer still names the published summary; the recording is unchanged."
|
||||
)
|
||||
logger.warning("%s", message)
|
||||
if on_warning is not None:
|
||||
try:
|
||||
on_warning(message)
|
||||
except Exception: # noqa: BLE001 - a reporter must not break the export
|
||||
logger.exception("The summary copy warning handler failed")
|
||||
|
||||
|
||||
def verify_inputs(root: Path, summary: dict) -> None:
|
||||
"""Refuse to publish numbers the files on disk can no longer reproduce."""
|
||||
fresh = review_session(root)
|
||||
@@ -311,8 +428,14 @@ def publish_summary(
|
||||
*,
|
||||
verify: bool = True,
|
||||
cancelled=None,
|
||||
on_warning: Callable[[str], None] | None = None,
|
||||
) -> Path:
|
||||
"""Write, verify, rename, then move the pointer. Returns the published directory."""
|
||||
"""Write, verify, rename, move the pointer, then mirror ``summary/``.
|
||||
|
||||
Returns the published directory under ``derived/``. A failure of the readable
|
||||
mirror is reported through ``on_warning`` (and the log) after the pointer has
|
||||
moved; it never turns a published summary into a failed export.
|
||||
"""
|
||||
root = Path(root).resolve()
|
||||
if not (root / "session.json").is_file():
|
||||
raise SummaryExportError(f"not a session directory: {root}")
|
||||
@@ -358,6 +481,8 @@ def publish_summary(
|
||||
raise SummaryExportError(f"could not publish the summary: {exc}") from exc
|
||||
pointer = _pointer_for(root, summary, target, hashes)
|
||||
write_json(root / POINTER_NAME, pointer)
|
||||
# The pointer is the authority from here on; the readable copy is optional.
|
||||
_publish_flat_copy(root, target, hashes, on_warning)
|
||||
return target
|
||||
|
||||
|
||||
|
||||
@@ -11,12 +11,18 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||
from orisys_app_shared.record import ExperimentRecorder
|
||||
|
||||
from .calibration_session import CalibrationSession, write_json
|
||||
from .capture_guard import CaptureGuard, capture_verdict, PENDING
|
||||
from .recording_options import (
|
||||
RecordingOptions,
|
||||
make_record_config,
|
||||
require_codec_available,
|
||||
)
|
||||
from .sensor.uv_flat import Site
|
||||
|
||||
|
||||
@@ -56,6 +62,9 @@ class ManualTrial:
|
||||
self.worker = worker
|
||||
self.session: CalibrationSession | None = None
|
||||
self.entry: dict | None = None
|
||||
#: The recording settings this session runs with (plan P2). The session
|
||||
#: manifest locks them at establishment; a later trial reuses them.
|
||||
self.recording = RecordingOptions()
|
||||
self.hold_s = 20.0
|
||||
self.completed_steps: list[dict] = []
|
||||
self.reason = ""
|
||||
@@ -91,6 +100,8 @@ class ManualTrial:
|
||||
config_ref: str,
|
||||
fps: float,
|
||||
uv_outline: dict | None = None,
|
||||
recording: RecordingOptions | None = None,
|
||||
codec_availability: Mapping[str, bool] | None = None,
|
||||
) -> Path:
|
||||
if self.status in {"recording", "closing"}:
|
||||
raise RuntimeError("a trial is already active")
|
||||
@@ -106,6 +117,24 @@ class ManualTrial:
|
||||
if not 10.0 <= float(hold_s) <= 30.0:
|
||||
raise ValueError("hold time must be between 10 and 30 seconds")
|
||||
self.hold_s = float(hold_s)
|
||||
# The session is the owner of the video choice: it is written into the
|
||||
# root manifest once and every later trial reuses it. The codec is
|
||||
# checked before anything is written, so an encoder this machine cannot
|
||||
# open is refused instead of silently replaced.
|
||||
options = RecordingOptions() if recording is None else recording
|
||||
if not isinstance(options, RecordingOptions):
|
||||
raise TypeError("recording must be a RecordingOptions instance")
|
||||
if self.session is None:
|
||||
require_codec_available(options, codec_availability)
|
||||
else:
|
||||
locked = RecordingOptions.from_block(self.session.document.get("recording"))
|
||||
if recording is not None and options != locked:
|
||||
raise ValueError(
|
||||
"recording settings are locked once the session has started; "
|
||||
"finish this session before changing them"
|
||||
)
|
||||
options = locked
|
||||
self.recording = options
|
||||
packet = self.worker.latest()
|
||||
if packet.capture_mono_ns is None:
|
||||
raise RuntimeError("the input has no frame-bound host timestamp")
|
||||
@@ -149,13 +178,14 @@ class ManualTrial:
|
||||
"reference_source": "manual_operator_confirmation",
|
||||
"reference_file": "reference.csv",
|
||||
"uv_outline": uv_outline,
|
||||
# The root manifest keeps this block once; the trial's own metadata
|
||||
# repeats it so a single trial folder states what it recorded.
|
||||
"recording": self.recording.as_block(),
|
||||
}
|
||||
if uv_outline is not None and any(uv_outline[key] != metadata["uv_definition"][key]
|
||||
for key in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
|
||||
raise ValueError("UV outline does not match the selected site mapping")
|
||||
recorder = ExperimentRecorder(
|
||||
RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True)
|
||||
)
|
||||
recorder = ExperimentRecorder(make_record_config(self.recording, fps))
|
||||
if self.session is None:
|
||||
self.session = CalibrationSession(Path(root).resolve(), metadata)
|
||||
elif Path(root).resolve() != self.session.directory.parent:
|
||||
@@ -390,6 +420,7 @@ class ManualTrial:
|
||||
reason=self.reason,
|
||||
guard=self.guard,
|
||||
elapsed_s=elapsed,
|
||||
video_expected=self.recording.video,
|
||||
)
|
||||
if verdict.status == PENDING:
|
||||
return self.status
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
"""The capture page's settings dialog: input source, bundle, session and recording.
|
||||
|
||||
The capture page itself shows the workflow; everything that is decided once per
|
||||
session lives here, so that starting a capture cannot switch pages or hide the
|
||||
live image behind a configuration form.
|
||||
|
||||
Three things this dialog owns beyond plain fields:
|
||||
|
||||
* **The codec probe.** Recording candidates are written and read back once per
|
||||
process before they are offered. A codec this machine cannot open is disabled
|
||||
with its reason shown, never silently replaced — a recording made under a
|
||||
different codec than the session manifest claims is not the evidence it says it
|
||||
is.
|
||||
* **The size estimate.** Bitrate times the planned duration is an estimate (the
|
||||
module says so wherever it is displayed) and the free space beside it is real;
|
||||
when less than twice the estimate is free the dialog warns before anything is
|
||||
recorded.
|
||||
* **The bundle.** Loading one rasterises the finger geometry; the page keeps that
|
||||
work, the button here only asks for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import outputs_dir
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..recording_options import CODEC_CANDIDATES, RecordingOptions, probe_codecs
|
||||
from ..sequence_io import format_duration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Product models a session can be recorded under. The stored value is this id.
|
||||
PRODUCT_MODELS: tuple[str, ...] = ("finger",)
|
||||
|
||||
#: Measured video bitrates used only for the volume estimate. The plan's baseline
|
||||
#: measured about 27 Mbit/s for ``mp4v`` on this view and about 5 Mbit/s for the
|
||||
#: H.264 sample; the slider is not an encoder parameter, so the number is labelled
|
||||
#: an estimate wherever it is shown.
|
||||
CODEC_MBIT_PER_S: dict[str, float] = {"mp4v": 27.0, "avc1": 5.0, "H264": 5.0}
|
||||
DEFAULT_MBIT_PER_S = 10.0
|
||||
|
||||
#: Planning allowance per hold: load, settle, confirm and unload around the hold
|
||||
#: itself. It only shapes the size estimate, never a recorded window.
|
||||
STEP_OVERHEAD_S = 5.0
|
||||
|
||||
#: One probe per process. The probe writes three real clips, and every window in
|
||||
#: this app would otherwise repeat it; the answer cannot change while the process
|
||||
#: runs because it is a property of this machine's OpenCV build.
|
||||
_PROBE_CACHE: dict[str, dict[str, bool]] = {}
|
||||
|
||||
|
||||
def probe_once(scratch: Path | None = None) -> dict[str, bool]:
|
||||
"""The codec availability for this machine, probed at most once per process."""
|
||||
directory = Path(scratch) if scratch is not None else Path(outputs_dir()) / "codec_probe"
|
||||
key = str(directory.resolve())
|
||||
if key not in _PROBE_CACHE:
|
||||
try:
|
||||
_PROBE_CACHE[key] = dict(probe_codecs(directory))
|
||||
except Exception as exc: # noqa: BLE001 - a probe is external and must not crash start-up
|
||||
logger.exception("Codec probe failed: %s", exc)
|
||||
_PROBE_CACHE[key] = {}
|
||||
return dict(_PROBE_CACHE[key])
|
||||
|
||||
|
||||
def format_size(value: float) -> str:
|
||||
"""A byte count in the unit an operator thinks in."""
|
||||
number = float(value)
|
||||
for unit, scale in (("GB", 1e9), ("MB", 1e6), ("kB", 1e3)):
|
||||
if number >= scale:
|
||||
return f"{number / scale:.2f} {unit}"
|
||||
return f"{number:.0f} B"
|
||||
|
||||
|
||||
class CaptureSettingsDialog(QDialog):
|
||||
"""Everything decided once per session, in one place."""
|
||||
|
||||
#: The operator asked for a bundle; the page (and the window) own the dialog.
|
||||
bundleRequested = Signal()
|
||||
#: A recorded option changed and should be remembered in the preferences.
|
||||
recordingChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_panel: QWidget,
|
||||
prefs: dict,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(t("cap.settings.title"))
|
||||
self.resize(720, 700)
|
||||
self.setModal(True)
|
||||
self.prefs = prefs
|
||||
self.source_panel = source_panel
|
||||
self._availability: dict[str, bool] | None = None
|
||||
self._probe_error = ""
|
||||
self._planned_seconds = 0.0
|
||||
self._locked = False
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea(self)
|
||||
scroll.setWidgetResizable(True)
|
||||
page = QWidget(scroll)
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setSpacing(10)
|
||||
scroll.setWidget(page)
|
||||
outer.addWidget(scroll, 1)
|
||||
|
||||
self.input_group = QGroupBox(page)
|
||||
self.input_group.setObjectName("groupBox")
|
||||
input_layout = QVBoxLayout(self.input_group)
|
||||
# The input panel is the live image plus its controls. It stays bounded
|
||||
# here: the dialog has four sections, and a preview that expands would push
|
||||
# the bundle, session and recording settings below the fold on every screen.
|
||||
source_panel.setMaximumHeight(320)
|
||||
source_panel.preview.setMinimumSize(140, 90)
|
||||
input_layout.addWidget(source_panel)
|
||||
self.input_group.setSizePolicy(
|
||||
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
|
||||
)
|
||||
layout.addWidget(self.input_group)
|
||||
|
||||
self.bundle_group = QGroupBox(page)
|
||||
self.bundle_group.setObjectName("groupBox")
|
||||
bundle_row = QHBoxLayout(self.bundle_group)
|
||||
self.bundle_label = QLabel(t("cap.settings.bundle_none"), self.bundle_group)
|
||||
self.bundle_label.setWordWrap(True)
|
||||
self.bundle_label.setObjectName("hintText")
|
||||
self.bundle_button = QPushButton(self.bundle_group)
|
||||
self.bundle_button.setObjectName("captureLoadBundle")
|
||||
self.bundle_button.clicked.connect(self.bundleRequested)
|
||||
bundle_row.addWidget(self.bundle_label, 1)
|
||||
bundle_row.addWidget(self.bundle_button)
|
||||
layout.addWidget(self.bundle_group)
|
||||
|
||||
self.session_group = QGroupBox(page)
|
||||
self.session_group.setObjectName("groupBox")
|
||||
session_grid = QGridLayout(self.session_group)
|
||||
session_grid.setHorizontalSpacing(12)
|
||||
session_grid.setVerticalSpacing(8)
|
||||
self.model_label = QLabel(self.session_group)
|
||||
self.sensor_model = QComboBox(self.session_group)
|
||||
self.sensor_model.setObjectName("captureSensorModel")
|
||||
self.sensor_model.addItems(list(PRODUCT_MODELS))
|
||||
self.sn_label = QLabel(self.session_group)
|
||||
self.sensor_sn = QLineEdit(self.session_group)
|
||||
self.sensor_sn.setObjectName("captureSensorSn")
|
||||
self.output_label = QLabel(self.session_group)
|
||||
self.output_path = QLineEdit(str(Path(outputs_dir()) / "calibrations"), self.session_group)
|
||||
self.output_path.setObjectName("captureOutputPath")
|
||||
self.output_browse = QPushButton(self.session_group)
|
||||
self.frame_label = QLabel(self.session_group)
|
||||
self.frame = QLineEdit("sensor", self.session_group)
|
||||
self.frame.setObjectName("captureCoordinateFrame")
|
||||
self.max_force_label = QLabel(self.session_group)
|
||||
self.max_force = QLineEdit(self.session_group)
|
||||
self.max_force.setObjectName("captureMaxForce")
|
||||
self.min_force_label = QLabel(self.session_group)
|
||||
self.min_force = QLineEdit(self.session_group)
|
||||
self.min_force.setObjectName("captureMinForce")
|
||||
for row, (label, field) in enumerate(
|
||||
(
|
||||
(self.model_label, self.sensor_model),
|
||||
(self.sn_label, self.sensor_sn),
|
||||
(self.output_label, self.output_path),
|
||||
(self.frame_label, self.frame),
|
||||
(self.max_force_label, self.max_force),
|
||||
(self.min_force_label, self.min_force),
|
||||
)
|
||||
):
|
||||
session_grid.addWidget(label, row, 0)
|
||||
session_grid.addWidget(field, row, 1)
|
||||
session_grid.addWidget(self.output_browse, 2, 2)
|
||||
self.simulate = QCheckBox(self.session_group)
|
||||
self.simulate.setObjectName("captureSimulate")
|
||||
session_grid.addWidget(self.simulate, 6, 0, 1, 3)
|
||||
layout.addWidget(self.session_group)
|
||||
|
||||
self.recording_group = QGroupBox(page)
|
||||
self.recording_group.setObjectName("groupBox")
|
||||
recording_layout = QGridLayout(self.recording_group)
|
||||
recording_layout.setHorizontalSpacing(12)
|
||||
recording_layout.setVerticalSpacing(6)
|
||||
self.video_check = QCheckBox(self.recording_group)
|
||||
self.video_check.setObjectName("captureRecordVideo")
|
||||
self.codec_label = QLabel(self.recording_group)
|
||||
self.codec = QComboBox(self.recording_group)
|
||||
self.codec.setObjectName("captureCodec")
|
||||
self.lossless_check = QCheckBox(self.recording_group)
|
||||
self.lossless_check.setObjectName("captureLossless")
|
||||
self.codec_reason = QLabel(self.recording_group)
|
||||
self.codec_reason.setWordWrap(True)
|
||||
self.codec_reason.setObjectName("hintText")
|
||||
self.estimate = QLabel(self.recording_group)
|
||||
self.estimate.setWordWrap(True)
|
||||
self.estimate.setObjectName("hintText")
|
||||
self.locked_label = QLabel(self.recording_group)
|
||||
self.locked_label.setWordWrap(True)
|
||||
self.locked_label.setObjectName("hintText")
|
||||
recording_layout.addWidget(self.video_check, 0, 0, 1, 2)
|
||||
recording_layout.addWidget(self.codec_label, 1, 0)
|
||||
recording_layout.addWidget(self.codec, 1, 1)
|
||||
recording_layout.addWidget(self.lossless_check, 2, 0, 1, 2)
|
||||
recording_layout.addWidget(self.codec_reason, 3, 0, 1, 2)
|
||||
recording_layout.addWidget(self.estimate, 4, 0, 1, 2)
|
||||
recording_layout.addWidget(self.locked_label, 5, 0, 1, 2)
|
||||
layout.addWidget(self.recording_group)
|
||||
layout.addStretch(1)
|
||||
|
||||
self.buttons = QDialogButtonBox(self)
|
||||
self.close_button = self.buttons.addButton(
|
||||
t("cap.settings.close"), QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
self.close_button.setObjectName("captureSettingsClose")
|
||||
outer.addWidget(self.buttons)
|
||||
|
||||
self.close_button.clicked.connect(self.close)
|
||||
self.output_browse.clicked.connect(self._choose_output)
|
||||
self.bundle_button.clicked.connect(self._choose_bundle)
|
||||
self.video_check.toggled.connect(self._on_recording_changed)
|
||||
self.codec.currentIndexChanged.connect(self._on_recording_changed)
|
||||
self.lossless_check.toggled.connect(self._on_recording_changed)
|
||||
self.output_path.textChanged.connect(self._refresh_estimate)
|
||||
self.sensor_sn.textChanged.connect(self._refresh_estimate)
|
||||
self.simulate.toggled.connect(self._refresh_estimate)
|
||||
self.set_availability(probe_once(), error="")
|
||||
self._load_preferences()
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------- preferences
|
||||
|
||||
def _load_preferences(self) -> None:
|
||||
block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None
|
||||
self._apply_options(RecordingOptions.from_block(block))
|
||||
|
||||
def _remember(self) -> None:
|
||||
if not isinstance(self.prefs, dict):
|
||||
return
|
||||
# Only the recording block is a preference: the sensor SN, the frame name
|
||||
# and the maximum force belong to the session being set up, not to the
|
||||
# operator's stored UI state, so they are not carried across restarts.
|
||||
self.prefs["recording"] = self.recording_options().as_block()
|
||||
|
||||
# ------------------------------------------------------------------ values
|
||||
|
||||
@property
|
||||
def availability(self) -> dict[str, bool] | None:
|
||||
"""The probed codec result the start path must honour (``None``: unprobed)."""
|
||||
return None if self._availability is None else dict(self._availability)
|
||||
|
||||
def recording_options(self) -> RecordingOptions:
|
||||
codec = self.codec.currentData()
|
||||
return RecordingOptions(
|
||||
video=self.video_check.isChecked(),
|
||||
codec=str(codec or CODEC_CANDIDATES[0]),
|
||||
lossless_frames=self.lossless_check.isChecked(),
|
||||
)
|
||||
|
||||
def _apply_options(self, options: RecordingOptions) -> None:
|
||||
self.video_check.setChecked(bool(options.video))
|
||||
index = self.codec.findData(options.codec)
|
||||
# A stored codec this machine cannot write must not become the selection:
|
||||
# ``set_availability`` already fell back to one that works, and re-selecting
|
||||
# the disabled entry would leave the operator pointing at a codec the start
|
||||
# path is about to refuse.
|
||||
if index >= 0 and self._codec_usable(index):
|
||||
self.codec.setCurrentIndex(index)
|
||||
self.lossless_check.setChecked(bool(options.lossless_frames))
|
||||
|
||||
def _codec_usable(self, index: int) -> bool:
|
||||
item = self.codec.model().item(index)
|
||||
return item is None or item.isEnabled()
|
||||
|
||||
def session_fields(self) -> dict:
|
||||
"""The session parameters as the page reads them when it starts a session."""
|
||||
return {
|
||||
"sensor_model": self.sensor_model.currentText(),
|
||||
"sensor_sn": self.sensor_sn.text().strip(),
|
||||
"root": self.output_path.text().strip(),
|
||||
"coordinate_frame": self.frame.text().strip(),
|
||||
"max_force_n": self.max_force.text().strip(),
|
||||
"min_force_n": self.min_force.text().strip(),
|
||||
"simulate": self.simulate.isChecked(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- probe
|
||||
|
||||
def set_availability(self, availability: dict[str, bool] | None, *, error: str = "") -> None:
|
||||
"""Offer only the codecs this machine can actually encode with."""
|
||||
self._availability = None if availability is None else dict(availability)
|
||||
self._probe_error = str(error or "")
|
||||
self.codec.blockSignals(True)
|
||||
try:
|
||||
self.codec.clear()
|
||||
for codec in CODEC_CANDIDATES:
|
||||
label = codec
|
||||
available = True
|
||||
if self._availability is not None:
|
||||
available = self._availability.get(codec) is True
|
||||
if not available:
|
||||
label = t("cap.recording.unavailable", codec=codec)
|
||||
self.codec.addItem(label, codec)
|
||||
item = self.codec.model().item(self.codec.count() - 1)
|
||||
if item is not None:
|
||||
item.setEnabled(available)
|
||||
wanted = str(self._stored_codec() or CODEC_CANDIDATES[0])
|
||||
index = self.codec.findData(wanted)
|
||||
if index < 0:
|
||||
index = 0
|
||||
self.codec.setCurrentIndex(index)
|
||||
# The stored codec can be one this machine no longer opens. Offer the
|
||||
# first available one instead, and keep the reason above visible: the
|
||||
# operator must not be left with a disabled selection and no way out.
|
||||
for candidate in range(self.codec.count()):
|
||||
item = self.codec.model().item(candidate)
|
||||
if item is not None and item.isEnabled():
|
||||
self.codec.setCurrentIndex(candidate)
|
||||
break
|
||||
finally:
|
||||
self.codec.blockSignals(False)
|
||||
self.codec.setToolTip(
|
||||
"\n".join(
|
||||
t("cap.recording.unavailable", codec=codec)
|
||||
for codec, ok in (self._availability or {}).items()
|
||||
if not ok
|
||||
)
|
||||
)
|
||||
self._refresh_estimate()
|
||||
|
||||
def _stored_codec(self) -> str:
|
||||
block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None
|
||||
if isinstance(block, dict) and isinstance(block.get("codec"), str):
|
||||
return block["codec"]
|
||||
return CODEC_CANDIDATES[0]
|
||||
|
||||
def _on_recording_changed(self) -> None:
|
||||
self._remember()
|
||||
self._refresh_estimate()
|
||||
self.recordingChanged.emit()
|
||||
|
||||
# ------------------------------------------------------------------ estimate
|
||||
|
||||
def set_plan(self, seconds: float) -> None:
|
||||
"""How long the queue is planned to record for, in seconds."""
|
||||
self._planned_seconds = max(0.0, float(seconds))
|
||||
self._refresh_estimate()
|
||||
|
||||
def _disk_root(self) -> Path:
|
||||
text = self.output_path.text().strip()
|
||||
candidate = Path(text) if text else Path.cwd()
|
||||
while not candidate.exists() and candidate != candidate.parent:
|
||||
candidate = candidate.parent
|
||||
return candidate if candidate.exists() else Path.cwd()
|
||||
|
||||
def _refresh_estimate(self) -> None:
|
||||
options = self.recording_options()
|
||||
if not options.video:
|
||||
text = t("cap.recording.off")
|
||||
if options.lossless_frames:
|
||||
text += "\n" + t("cap.recording.lossless")
|
||||
self.estimate.setText(text)
|
||||
return
|
||||
if self._planned_seconds <= 0:
|
||||
self.estimate.setText(t("cap.recording.no_plan"))
|
||||
return
|
||||
codec = str(options.codec)
|
||||
mbit = CODEC_MBIT_PER_S.get(codec, DEFAULT_MBIT_PER_S)
|
||||
size = mbit * 1e6 / 8.0 * self._planned_seconds
|
||||
lines = []
|
||||
try:
|
||||
free = shutil.disk_usage(self._disk_root()).free
|
||||
except OSError:
|
||||
free = None
|
||||
if free is None:
|
||||
lines.append(
|
||||
t("cap.recording.estimate", size=format_size(size),
|
||||
seconds=format_duration(self._planned_seconds), free="—")
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
t("cap.recording.estimate", size=format_size(size),
|
||||
seconds=format_duration(self._planned_seconds), free=format_size(free))
|
||||
)
|
||||
if free < 2.0 * size:
|
||||
lines.append(t("cap.recording.disk_short"))
|
||||
self.estimate.setText("\n".join(lines))
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
def set_locked(self, locked: bool) -> None:
|
||||
"""A started session locks its recording and session parameters."""
|
||||
if bool(locked) == self._locked and self.locked_label.isVisible() == self._locked:
|
||||
return
|
||||
self._locked = bool(locked)
|
||||
for widget in (
|
||||
self.input_group, self.bundle_button, self.session_group,
|
||||
self.sensor_model, self.sensor_sn, self.output_path, self.output_browse,
|
||||
self.frame, self.max_force, self.min_force, self.simulate,
|
||||
self.video_check, self.codec, self.lossless_check,
|
||||
):
|
||||
widget.setEnabled(not self._locked)
|
||||
self.locked_label.setText(t("cap.recording.locked") if self._locked else "")
|
||||
self.locked_label.setVisible(self._locked)
|
||||
|
||||
def set_bundle(self, path: str) -> None:
|
||||
text = str(path or "").strip()
|
||||
self.bundle_label.setText(text or t("cap.settings.bundle_none"))
|
||||
self.bundle_label.setToolTip(text)
|
||||
|
||||
def refresh_sizes(self) -> None:
|
||||
"""Re-read free space; opening the dialog again refreshes the estimate."""
|
||||
self._refresh_estimate()
|
||||
|
||||
def _choose_output(self) -> None:
|
||||
path = QFileDialog.getExistingDirectory(self, t("calibration.output"), self.output_path.text())
|
||||
if path:
|
||||
self.output_path.setText(path)
|
||||
|
||||
def _choose_bundle(self) -> None:
|
||||
self.bundleRequested.emit()
|
||||
|
||||
# -------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("cap.settings.title"))
|
||||
self.input_group.setTitle(t("cap.settings.input"))
|
||||
self.bundle_group.setTitle(t("cap.settings.bundle"))
|
||||
self.bundle_button.setText(t("cap.settings.bundle_load"))
|
||||
self.session_group.setTitle(t("cap.settings.session"))
|
||||
self.model_label.setText(t("calibration.sensor_model"))
|
||||
self.sn_label.setText(t("calibration.sensor_sn"))
|
||||
self.output_label.setText(t("calibration.output"))
|
||||
self.output_browse.setText(t("source.browse"))
|
||||
self.frame_label.setText(t("multipoint.frame"))
|
||||
self.max_force_label.setText(t("multipoint.max_force"))
|
||||
self.max_force.setPlaceholderText(t("cap.settings.max_required"))
|
||||
self.min_force_label.setText(t("multipoint.min_force"))
|
||||
self.min_force.setPlaceholderText(t("multipoint.min_placeholder"))
|
||||
self.simulate.setText(t("cap.settings.simulate"))
|
||||
self.recording_group.setTitle(t("cap.recording.title"))
|
||||
self.video_check.setText(t("cap.recording.video"))
|
||||
self.codec_label.setText(t("cap.recording.codec"))
|
||||
self.lossless_check.setText(t("cap.recording.lossless"))
|
||||
if self._probe_error:
|
||||
self.codec_reason.setText(t("cap.recording.probe_failed", detail=self._probe_error))
|
||||
else:
|
||||
self.codec_reason.setText(
|
||||
"\n".join(
|
||||
t("cap.recording.unavailable", codec=codec)
|
||||
for codec, ok in (self._availability or {}).items()
|
||||
if not ok
|
||||
)
|
||||
)
|
||||
self.close_button.setText(t("cap.settings.close"))
|
||||
self._refresh_estimate()
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802 - Qt override
|
||||
super().showEvent(event)
|
||||
self._refresh_estimate()
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802 - Qt override
|
||||
self._remember()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""The processed camera image, shown beside the UV map on the capture page.
|
||||
|
||||
The capture page needs the live 2-D UV image and the pickable UV map *at the same
|
||||
time*: the operator confirms a point's position on the map and watches the same
|
||||
frame the recorder sees. This widget therefore reads the already-running input
|
||||
worker; it never starts, stops or reconfigures it — the input source panel in the
|
||||
settings dialog owns that.
|
||||
|
||||
Multiple readers of ``worker.latest()`` are allowed: the input panel keeps its own
|
||||
copy for the settings dialog, and this view keeps one here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class LiveUvView(QWidget):
|
||||
"""Show the latest processed frame and the identity of the packet behind it."""
|
||||
|
||||
def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.source_panel = source_panel
|
||||
self._identity: tuple[int, int] | None = None
|
||||
self._image: QImage | None = None
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(2)
|
||||
self.preview = QLabel(self)
|
||||
self.preview.setObjectName("liveUvPreview")
|
||||
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# No minimum of its own: the image scales to whatever room the row gives
|
||||
# it, and a minimum would overflow the row the moment the page is squeezed.
|
||||
self.preview.setMinimumSize(1, 1)
|
||||
self.preview.setSizePolicy(
|
||||
# The image scales to whatever room the row gives it; a minimum that
|
||||
# honoured the frame's own size would push the page past the window.
|
||||
QSizePolicy.Policy.Ignored,
|
||||
QSizePolicy.Policy.Ignored,
|
||||
)
|
||||
self.status = QLabel(self)
|
||||
self.status.setWordWrap(True)
|
||||
self.status.setObjectName("hintText")
|
||||
layout.addWidget(self.preview, 1)
|
||||
layout.addWidget(self.status)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(100)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
self.retranslate()
|
||||
self.refresh()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
if self._image is None:
|
||||
self.preview.setText(t("source.no_frame"))
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
worker = getattr(self.source_panel, "worker", None)
|
||||
packet = None if worker is None else worker.latest()
|
||||
if packet is not None:
|
||||
identity = (int(packet.session_id), int(packet.frame_id))
|
||||
if identity != self._identity:
|
||||
self._identity = identity
|
||||
self._show(packet.processed_frame)
|
||||
running = bool(getattr(worker, "is_running", False))
|
||||
if packet is None:
|
||||
# The preview already says there is no frame; the line below it says
|
||||
# whether the input is running, which is the fact an operator needs.
|
||||
self.status.setText(t("source.running") if running else t("source.stopped"))
|
||||
return
|
||||
self.status.setText(
|
||||
t("source.frame").format(
|
||||
frame=packet.frame_id,
|
||||
source_frame=getattr(packet, "source_frame_id", None) or "–",
|
||||
basis=t("source.basis_unknown"),
|
||||
time="—",
|
||||
dropped=getattr(packet, "capture_dropped", 0),
|
||||
)
|
||||
)
|
||||
|
||||
def _show(self, frame) -> None:
|
||||
data = np.asarray(frame)
|
||||
if data.ndim != 3 or data.shape[2] != 3:
|
||||
return
|
||||
height, width = data.shape[:2]
|
||||
self._image = QImage(
|
||||
data.data, width, height, int(data.strides[0]), QImage.Format.Format_BGR888
|
||||
).copy()
|
||||
self._scale()
|
||||
|
||||
def _scale(self) -> None:
|
||||
if self._image is None:
|
||||
return
|
||||
self.preview.setPixmap(
|
||||
QPixmap.fromImage(self._image).scaled(
|
||||
self.preview.size(),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
|
||||
def resizeEvent(self, event) -> None: # noqa: N802 - Qt override
|
||||
super().resizeEvent(event)
|
||||
self._scale()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._timer.stop()
|
||||
@@ -1,746 +0,0 @@
|
||||
"""02 multi-point acquisition. Manual loads only; this page never moves the platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..multipoint import Campaign, next_point_id
|
||||
from ..sensor.frames import machine_table_xy
|
||||
from ..sensor.uv_flat import site_at
|
||||
|
||||
#: A stored point and the mapping's answer for the same UV are the same point when
|
||||
#: they agree this closely. Further apart means the map, the bundle or the mounting
|
||||
#: moved under the record, and the point has to be taken again rather than retargeted.
|
||||
_POINT_MATCH_MM = 0.05
|
||||
_NORMAL_MATCH = 1e-3
|
||||
|
||||
|
||||
class _WrappingLabel(QLabel):
|
||||
"""Keep wrapped text inside its own row instead of painting over the next control."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWordWrap(True)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
height = self.heightForWidth(self.width())
|
||||
if height > 0 and self.minimumHeight() != height:
|
||||
self.setMinimumHeight(height)
|
||||
self.updateGeometry()
|
||||
|
||||
|
||||
class MultipointPanel(QWidget):
|
||||
"""Add points, then record them in order after each load is confirmed by hand."""
|
||||
|
||||
alignRequested = Signal(object)
|
||||
|
||||
def __init__(self, host: QWidget, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.host = host
|
||||
self.campaign = Campaign()
|
||||
self._selected = ""
|
||||
self._offered_liftoff = ""
|
||||
self._finger = None
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea(self)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
outer.addWidget(scroll)
|
||||
page = QWidget(scroll)
|
||||
page.setMinimumWidth(560)
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(12)
|
||||
scroll.setWidget(page)
|
||||
|
||||
self.hint = _WrappingLabel(page)
|
||||
layout.addWidget(self.hint)
|
||||
|
||||
self.site_id = QLineEdit(page)
|
||||
self.repeats = QSpinBox(page)
|
||||
self.repeats.setRange(1, 99)
|
||||
self.repeats.setValue(1)
|
||||
self.repeats.setMinimumWidth(160)
|
||||
self.frame = QLineEdit(page)
|
||||
self.max_force = QLineEdit(page)
|
||||
self.min_force = QLineEdit(page)
|
||||
self.id_label = QLabel(page)
|
||||
self.repeats_label = QLabel(page)
|
||||
self.frame_label = QLabel(page)
|
||||
self.force_label = QLabel(page)
|
||||
self.min_label = QLabel(page)
|
||||
form = QGridLayout()
|
||||
form.setHorizontalSpacing(16)
|
||||
form.setVerticalSpacing(10)
|
||||
form.setColumnMinimumWidth(0, 156)
|
||||
form.setColumnStretch(1, 1)
|
||||
fields = (
|
||||
(self.id_label, self.site_id),
|
||||
(self.repeats_label, self.repeats),
|
||||
(self.frame_label, self.frame),
|
||||
(self.min_label, self.min_force),
|
||||
(self.force_label, self.max_force),
|
||||
)
|
||||
for row, (label, field) in enumerate(fields):
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
form.addWidget(label, row, 0)
|
||||
form.addWidget(field, row, 1)
|
||||
layout.addLayout(form)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.setSpacing(8)
|
||||
self.add_button = QPushButton(page)
|
||||
self.update_button = QPushButton(page)
|
||||
self.remove_button = QPushButton(page)
|
||||
self.up_button = QPushButton(page)
|
||||
self.down_button = QPushButton(page)
|
||||
for button in (
|
||||
self.add_button, self.update_button, self.remove_button,
|
||||
self.up_button, self.down_button,
|
||||
):
|
||||
buttons.addWidget(button, 1)
|
||||
layout.addLayout(buttons)
|
||||
extra = QHBoxLayout()
|
||||
extra.setSpacing(8)
|
||||
self.align_button = QPushButton(page)
|
||||
self.reacquire_button = QPushButton(page)
|
||||
for button in (self.align_button, self.reacquire_button):
|
||||
extra.addWidget(button, 1)
|
||||
layout.addLayout(extra)
|
||||
|
||||
self.table = QTableWidget(0, 8, page)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self.table.setMinimumHeight(160)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.itemSelectionChanged.connect(self._on_select)
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
self.progress = _WrappingLabel(page)
|
||||
self.save_status = _WrappingLabel(page)
|
||||
self.capture_status = _WrappingLabel(page)
|
||||
self.capture_status.setObjectName("multipointCapture")
|
||||
layout.addWidget(self.progress)
|
||||
layout.addWidget(self.save_status)
|
||||
layout.addWidget(self.capture_status)
|
||||
self.pose_status = _WrappingLabel(page)
|
||||
layout.addWidget(self.pose_status)
|
||||
|
||||
self.source_label = QLabel(page)
|
||||
self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.source_label.setMinimumWidth(156)
|
||||
self.source = QComboBox(page)
|
||||
self.source.addItem("", "weight")
|
||||
self.source.addItem("", "wrench")
|
||||
source_row = QHBoxLayout()
|
||||
source_row.setSpacing(16)
|
||||
source_row.addWidget(self.source_label)
|
||||
source_row.addWidget(self.source, 1)
|
||||
layout.addLayout(source_row)
|
||||
|
||||
self.wrench_box = QWidget(page)
|
||||
wrench = QGridLayout(self.wrench_box)
|
||||
wrench.setContentsMargins(0, 0, 0, 0)
|
||||
wrench.setHorizontalSpacing(8)
|
||||
wrench.setVerticalSpacing(8)
|
||||
self.fx_label = QLabel(self.wrench_box)
|
||||
self.fy_label = QLabel(self.wrench_box)
|
||||
self.fz_label = QLabel(self.wrench_box)
|
||||
self.fx_edit = QLineEdit(self.wrench_box)
|
||||
self.fy_edit = QLineEdit(self.wrench_box)
|
||||
self.fz_edit = QLineEdit(self.wrench_box)
|
||||
self.rotation_label = QLabel(self.wrench_box)
|
||||
self.rotation = QLineEdit(self.wrench_box)
|
||||
wrench.addWidget(self.fx_label, 0, 0)
|
||||
wrench.addWidget(self.fx_edit, 0, 1)
|
||||
wrench.addWidget(self.fy_label, 0, 2)
|
||||
wrench.addWidget(self.fy_edit, 0, 3)
|
||||
wrench.addWidget(self.fz_label, 0, 4)
|
||||
wrench.addWidget(self.fz_edit, 0, 5)
|
||||
wrench.addWidget(self.rotation_label, 1, 0)
|
||||
wrench.addWidget(self.rotation, 1, 1, 1, 5)
|
||||
layout.addWidget(self.wrench_box)
|
||||
|
||||
actions = QGridLayout()
|
||||
actions.setHorizontalSpacing(8)
|
||||
actions.setVerticalSpacing(8)
|
||||
self.simulate_button = QPushButton(page)
|
||||
self.start_button = QPushButton(page)
|
||||
self.liftoff_button = QPushButton(page)
|
||||
self.remeasure_button = QPushButton(page)
|
||||
self.stop_button = QPushButton(page)
|
||||
actions.addWidget(self.simulate_button, 0, 0)
|
||||
actions.addWidget(self.start_button, 0, 1)
|
||||
actions.addWidget(self.liftoff_button, 1, 0, 1, 2)
|
||||
actions.addWidget(self.remeasure_button, 2, 0, 1, 2)
|
||||
actions.addWidget(self.stop_button, 3, 0, 1, 2)
|
||||
actions.setColumnStretch(0, 1)
|
||||
actions.setColumnStretch(1, 1)
|
||||
layout.addLayout(actions)
|
||||
self.message = _WrappingLabel(page)
|
||||
layout.addWidget(self.message)
|
||||
|
||||
self.add_button.clicked.connect(self._add)
|
||||
self.update_button.clicked.connect(self._update)
|
||||
self.remove_button.clicked.connect(self._remove)
|
||||
self.up_button.clicked.connect(lambda: self._move(-1))
|
||||
self.down_button.clicked.connect(lambda: self._move(1))
|
||||
self.align_button.clicked.connect(self._align)
|
||||
self.reacquire_button.clicked.connect(self._reacquire)
|
||||
self.source.currentIndexChanged.connect(lambda _index: self._refresh())
|
||||
self.simulate_button.clicked.connect(lambda: self._begin(simulate=True))
|
||||
self.start_button.clicked.connect(lambda: self._begin(simulate=False))
|
||||
self.liftoff_button.clicked.connect(self._liftoff)
|
||||
self.remeasure_button.clicked.connect(self._remeasure)
|
||||
self.stop_button.clicked.connect(self._stop)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(200)
|
||||
self._timer.timeout.connect(self._poll)
|
||||
self._timer.start()
|
||||
self._listed: tuple = ()
|
||||
self._summary_published_for = None
|
||||
self.retranslate()
|
||||
self.site_id.setText(self._next_site_id())
|
||||
self.frame.setText("sensor")
|
||||
self._refresh()
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.campaign.is_active
|
||||
|
||||
def bind_finger(self, panel) -> None:
|
||||
"""The finger page whose picked points and solved poses this tab records."""
|
||||
self._finger = panel
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._timer.stop()
|
||||
if self.campaign.is_active:
|
||||
self.campaign.stop("application closed")
|
||||
# Bounded only at close time: the window cannot wait forever, and a
|
||||
# writer that misses the deadline leaves an explicitly incomplete repeat.
|
||||
self.campaign.flush_close()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.hint.setText(t("multipoint.hint"))
|
||||
self.id_label.setText(t("multipoint.site_id"))
|
||||
self.repeats_label.setText(t("multipoint.repeats"))
|
||||
self.frame_label.setText(t("multipoint.frame"))
|
||||
self.force_label.setText(t("multipoint.max_force"))
|
||||
self.min_label.setText(t("multipoint.min_force"))
|
||||
self.frame.setPlaceholderText(t("multipoint.frame_placeholder"))
|
||||
self.max_force.setPlaceholderText(t("multipoint.force_placeholder"))
|
||||
self.min_force.setPlaceholderText(t("multipoint.min_placeholder"))
|
||||
self.add_button.setText(t("multipoint.add"))
|
||||
self.update_button.setText(t("multipoint.update"))
|
||||
self.remove_button.setText(t("multipoint.remove"))
|
||||
self.up_button.setText(t("multipoint.up"))
|
||||
self.down_button.setText(t("multipoint.down"))
|
||||
self.align_button.setText(t("multipoint.align"))
|
||||
self.reacquire_button.setText(t("multipoint.reacquire"))
|
||||
self.source_label.setText(t("multipoint.source"))
|
||||
self.source.setItemText(0, t("multipoint.source_weight"))
|
||||
self.source.setItemText(1, t("multipoint.source_wrench"))
|
||||
self.fx_label.setText(t("multipoint.fx"))
|
||||
self.fy_label.setText(t("multipoint.fy"))
|
||||
self.fz_label.setText(t("multipoint.fz"))
|
||||
self.rotation_label.setText(t("multipoint.rotation"))
|
||||
self.rotation.setPlaceholderText(t("multipoint.rotation_placeholder"))
|
||||
self.simulate_button.setText(t("multipoint.simulate"))
|
||||
self.start_button.setText(t("multipoint.start"))
|
||||
self.liftoff_button.setText(t("multipoint.liftoff"))
|
||||
self.remeasure_button.setText(t("multipoint.remeasure"))
|
||||
self.remeasure_button.setToolTip(t("multipoint.remeasure_ready"))
|
||||
self.stop_button.setText(t("multipoint.stop"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
t("multipoint.col_id"), t("multipoint.col_x"), t("multipoint.col_y"),
|
||||
t("multipoint.col_z"), t("multipoint.col_levels"), t("multipoint.col_hold"),
|
||||
t("multipoint.col_repeats"), t("multipoint.col_done"),
|
||||
])
|
||||
self._refresh()
|
||||
|
||||
def _next_site_id(self) -> str:
|
||||
return next_point_id([site.site_id for site in self.campaign.sites])
|
||||
|
||||
def _levels(self) -> list[float]:
|
||||
return [float(part.strip()) for part in self.host.levels.text().split(",") if part.strip()]
|
||||
|
||||
def _hold(self) -> float:
|
||||
return float(self.host.hold.value())
|
||||
|
||||
def _solver(self):
|
||||
finger = self._finger
|
||||
stack = None if finger is None else getattr(finger, "stack", None)
|
||||
if stack is None:
|
||||
raise ValueError(t("multipoint.no_solver"))
|
||||
return stack
|
||||
|
||||
@staticmethod
|
||||
def _pose_of(solved, stack) -> dict:
|
||||
"""The record of one solve: the targets, and what the solver said of them."""
|
||||
move_x, move_y = machine_table_xy(solved.table_xy_mm)
|
||||
return {
|
||||
"x": float(move_x),
|
||||
"y": float(move_y),
|
||||
"yaw": float(solved.yaw_deg),
|
||||
"pitch": float(solved.pitch_deg),
|
||||
"z": float(solved.contact_z_mm),
|
||||
"residual_deg": float(solved.residual_deg),
|
||||
"upright": bool(solved.upright),
|
||||
"in_range": bool(solved.ok and solved.in_range),
|
||||
"ok": bool(solved.ok),
|
||||
"normal_aligned_to": f"tool_axis:{stack.tool_axis}",
|
||||
"reason": str(solved.reason or ""),
|
||||
}
|
||||
|
||||
def _solve_uv(self, u: float, v: float):
|
||||
"""Pose for the point currently chosen on 选择测点. Does not move the platform."""
|
||||
flat = getattr(self.host, "_flat", None)
|
||||
if flat is None:
|
||||
raise ValueError(t("multipoint.no_map"))
|
||||
site = site_at(flat, int(round(u)), int(round(v)), origin=getattr(self.host, "_origin", None))
|
||||
sample = flat.sample(*site.raster_xy_px)
|
||||
if sample is None:
|
||||
raise ValueError(t("multipoint.mesh_required"))
|
||||
point, normal, _face = sample
|
||||
stack = self._solver()
|
||||
solved = stack.solve(
|
||||
np.asarray(point, dtype=np.float64),
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
)
|
||||
length = float(np.linalg.norm(normal))
|
||||
unit = tuple(float(value) / length for value in normal) if length >= 1e-9 else tuple(
|
||||
float(value) for value in normal
|
||||
)
|
||||
return (
|
||||
(float(point[0]), float(point[1]), float(point[2])),
|
||||
unit,
|
||||
self._pose_of(solved, stack),
|
||||
(float(site.u_px), float(site.v_px)),
|
||||
)
|
||||
|
||||
def _solve_point(self, point, normal) -> dict:
|
||||
"""Pose for a recorded sensor-frame point, against the geometry in force now."""
|
||||
stack = self._solver()
|
||||
solved = stack.solve(
|
||||
np.asarray(point, dtype=np.float64),
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
)
|
||||
return self._pose_of(solved, stack)
|
||||
|
||||
def _solve_current(self):
|
||||
return self._solve_uv(float(self.host.u_spin.value()), float(self.host.v_spin.value()))
|
||||
|
||||
def _store_pose(self, site_id: str, point, normal, pose, uv) -> None:
|
||||
site = next(item for item in self.campaign.sites if item.site_id == site_id)
|
||||
site.x, site.y, site.z = point
|
||||
site.normal = normal
|
||||
site.pose = pose
|
||||
site.uv_px = uv
|
||||
site.position_frame = "sensor"
|
||||
|
||||
def _store_solved_pose(self, site_id: str, pose: dict) -> None:
|
||||
"""Record a fresh solve of a point without retaking the point itself."""
|
||||
self.campaign.store_solved_pose(site_id, pose)
|
||||
|
||||
def _require_same_point(self, site, point, normal) -> None:
|
||||
"""Refuse a point the current map no longer puts where the record says.
|
||||
|
||||
A re-solve is allowed to answer differently; it is not allowed to move the
|
||||
point quietly. The recorded position is what a session's rows are
|
||||
attributed to, so a changed map, bundle or mounting has to reach the
|
||||
operator as "take this point again", never as a silently retargeted move.
|
||||
"""
|
||||
if site.position_frame != "sensor" or site.normal is None:
|
||||
return
|
||||
stored = np.asarray((site.x, site.y, site.z), dtype=np.float64)
|
||||
moved = float(np.linalg.norm(np.asarray(point, dtype=np.float64) - stored))
|
||||
if moved > _POINT_MATCH_MM:
|
||||
raise ValueError(t("multipoint.point_moved").format(mm=f"{moved:.2f}"))
|
||||
dot = float(np.dot(
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
np.asarray(site.normal, dtype=np.float64),
|
||||
))
|
||||
turned = float(np.degrees(np.arccos(np.clip(dot, -1.0, 1.0))))
|
||||
if turned > _NORMAL_MATCH:
|
||||
raise ValueError(t("multipoint.normal_moved").format(deg=f"{turned:.2f}"))
|
||||
|
||||
def _resolve_site(self, site) -> dict:
|
||||
"""The pose to align now, solved against the geometry in force now.
|
||||
|
||||
``site.pose`` records what the solver said when the point was taken. The
|
||||
mounting offsets, the mounting angle, the tool position and the travel
|
||||
limits are all editable afterwards, so a stored pose can name a target
|
||||
this machine no longer reaches — or reach the same point a different way.
|
||||
The closed form is cheap and stateless, so this asks it again instead of
|
||||
keeping a cache and a rule for when to distrust it.
|
||||
"""
|
||||
if site.uv_px is not None:
|
||||
point, normal, pose, _uv = self._solve_uv(*site.uv_px)
|
||||
self._require_same_point(site, point, normal)
|
||||
self._store_solved_pose(site.site_id, pose)
|
||||
return pose
|
||||
if site.normal is None or site.position_frame != "sensor":
|
||||
raise ValueError(t("multipoint.point_unmapped"))
|
||||
pose = self._solve_point((site.x, site.y, site.z), site.normal)
|
||||
self._store_solved_pose(site.site_id, pose)
|
||||
return pose
|
||||
|
||||
def _pose_for_display(self, site):
|
||||
"""What the status line shows: solved now, or the record when that fails.
|
||||
|
||||
The line is read before a move, so it has to answer for the geometry in
|
||||
force now; a stored pose the current mounting no longer reaches would
|
||||
otherwise read as reachable right up to the refusal.
|
||||
"""
|
||||
if site is None:
|
||||
return None
|
||||
if self.campaign.is_active:
|
||||
return site.pose
|
||||
try:
|
||||
return self._resolve_site(site)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return site.pose
|
||||
|
||||
def _max_force(self) -> float:
|
||||
text = self.max_force.text().strip()
|
||||
if not text:
|
||||
raise ValueError(t("multipoint.force_required"))
|
||||
return float(text)
|
||||
|
||||
def _min_force(self) -> float:
|
||||
text = self.min_force.text().strip()
|
||||
return 0.0 if not text else float(text)
|
||||
|
||||
def _add(self) -> None:
|
||||
def go():
|
||||
if not self.site_id.text().strip():
|
||||
self.site_id.setText(self._next_site_id())
|
||||
point, normal, pose, uv = self._solve_current()
|
||||
self.campaign.add_solved_site(
|
||||
self.site_id.text(), point[0], point[1], point[2], normal, pose,
|
||||
self._levels(), self._hold(), self.repeats.value(),
|
||||
max_force_n=self._max_force(), min_force_n=self._min_force(), uv_px=uv,
|
||||
)
|
||||
self.table.clearSelection()
|
||||
self._selected = ""
|
||||
self.site_id.setText(self._next_site_id())
|
||||
self._run(go)
|
||||
|
||||
def _update(self) -> None:
|
||||
if not self._selected:
|
||||
return
|
||||
def go():
|
||||
point, normal, pose, uv = self._solve_current()
|
||||
new_id = self.site_id.text().strip()
|
||||
self.campaign.update_site(
|
||||
self._selected, point[0], point[1], point[2], self._levels(), self._hold(),
|
||||
self.repeats.value(), max_force_n=self._max_force(), new_id=new_id,
|
||||
)
|
||||
self._store_pose(new_id or self._selected, point, normal, pose, uv)
|
||||
self._run(go)
|
||||
|
||||
def _remove(self) -> None:
|
||||
if self._selected:
|
||||
self._run(lambda: self.campaign.remove_site(self._selected))
|
||||
|
||||
def _move(self, delta: int) -> None:
|
||||
if self._selected:
|
||||
self._run(lambda: self.campaign.move_site(self._selected, delta))
|
||||
|
||||
def _begin(self, *, simulate: bool) -> None:
|
||||
def go():
|
||||
if self.host.trial.status in {"recording", "closing"}:
|
||||
raise RuntimeError(t("multipoint.finger_busy"))
|
||||
self._summary_published_for = None
|
||||
if not simulate:
|
||||
worker = self.host.source_panel.worker
|
||||
packet = worker.latest()
|
||||
mode = getattr(self.host.source_panel, "mode", None)
|
||||
if not worker.is_running or packet is None or mode is None:
|
||||
raise RuntimeError(t("multipoint.camera_required"))
|
||||
directory = self.campaign.start(
|
||||
self.host.output_path.text().strip(),
|
||||
sensor_model=self.host.sensor_model.currentText(),
|
||||
sensor_sn=self.host.sensor_sn.text(),
|
||||
coordinate_frame=self.frame.text().strip() or "sensor",
|
||||
max_force_n=self._max_force(),
|
||||
min_force_n=self._min_force(),
|
||||
simulate=simulate,
|
||||
)
|
||||
if not simulate:
|
||||
try:
|
||||
self.campaign.attach_capture(
|
||||
self.host.source_panel.worker,
|
||||
fps=float(self.host.source_panel.mode.fps),
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
# A repeat that cannot record its frames must not be offered
|
||||
# for confirmation: stop it rather than collect unattached
|
||||
# holds that would look complete.
|
||||
self.campaign.stop(str(exc))
|
||||
raise
|
||||
self.message.setText(str(directory))
|
||||
self._run(go)
|
||||
|
||||
def _confirm(self) -> None:
|
||||
if self.source.currentData() == "wrench":
|
||||
parts = [part for part in self.rotation.text().replace(",", " ").split() if part]
|
||||
self._run(lambda: (
|
||||
self.campaign.set_force_rotation([float(part) for part in parts]),
|
||||
self.campaign.confirm_wrench(
|
||||
float(self.fx_edit.text()), float(self.fy_edit.text()), float(self.fz_edit.text()),
|
||||
),
|
||||
))
|
||||
return
|
||||
self._run(lambda: self.campaign.confirm_reference(float(self.host.reference.text())))
|
||||
|
||||
def confirm_loaded(self) -> None:
|
||||
"""The right-hand confirm button records the current multi-point step."""
|
||||
self._confirm()
|
||||
|
||||
def _can_align(self) -> bool:
|
||||
return bool(self._selected) and (
|
||||
not self.campaign.is_active
|
||||
or self._selected == self.campaign.pending_alignment_site
|
||||
)
|
||||
|
||||
def _align(self) -> None:
|
||||
if not self._selected:
|
||||
self.message.setText(t("multipoint.align_select"))
|
||||
return
|
||||
if not self._can_align():
|
||||
# Moving the platform in the middle of a run would invalidate the trial
|
||||
# being recorded, so a second point waits until this one is finished.
|
||||
self.message.setText(t("multipoint.align_busy"))
|
||||
return
|
||||
def go():
|
||||
site = next(item for item in self.campaign.sites if item.site_id == self._selected)
|
||||
# Solved, not looked up: the stored pose may predate any of the
|
||||
# mounting, tool or travel settings the operator has changed since.
|
||||
pose = self._resolve_site(site)
|
||||
if not pose.get("ok") or not pose.get("in_range"):
|
||||
raise ValueError(t("multipoint.pose_refused").format(reason=pose.get("reason") or ""))
|
||||
self.alignRequested.emit(self.campaign.command_targets(self._selected))
|
||||
self._run(go)
|
||||
|
||||
def _reacquire(self) -> None:
|
||||
if self._selected:
|
||||
self._summary_published_for = None
|
||||
self._run(lambda: self.campaign.reacquire(self._selected))
|
||||
|
||||
def _liftoff(self) -> None:
|
||||
self._run(self.campaign.confirm_liftoff)
|
||||
|
||||
def _remeasure(self) -> None:
|
||||
def go():
|
||||
self.campaign.remeasure_step()
|
||||
self.message.setText(t("multipoint.remeasure_ready"))
|
||||
self._run(go)
|
||||
|
||||
def _stop(self) -> None:
|
||||
self.campaign.stop(t("multipoint.stopped_reason"))
|
||||
self._refresh()
|
||||
|
||||
def _poll(self) -> None:
|
||||
try:
|
||||
self.campaign.poll()
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
if self.campaign.is_active:
|
||||
self.campaign.stop(str(exc))
|
||||
if self.campaign.phase == "hold" and self.campaign.remaining_s() <= 0:
|
||||
try:
|
||||
self.campaign.complete_hold()
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
if self.campaign.is_active:
|
||||
self.campaign.stop(str(exc))
|
||||
self._publish_summary_when_finished()
|
||||
self._refresh()
|
||||
|
||||
def _publish_summary_when_finished(self) -> None:
|
||||
"""The candidate export starts when the campaign has closed its evidence."""
|
||||
directory = self.campaign.directory
|
||||
if directory is None or self.campaign.phase not in {"done", "aborted"}:
|
||||
return
|
||||
if self.campaign.capture_state == "closing":
|
||||
return
|
||||
if directory == self._summary_published_for:
|
||||
return
|
||||
if self.host.publish_summary_for(directory):
|
||||
self._summary_published_for = directory
|
||||
|
||||
def _run(self, action) -> None:
|
||||
try:
|
||||
action()
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
self._refresh()
|
||||
|
||||
def _on_select(self) -> None:
|
||||
rows = self.table.selectionModel().selectedRows()
|
||||
if not rows:
|
||||
return
|
||||
site = self.campaign.sites[rows[0].row()]
|
||||
self._selected = site.site_id
|
||||
self.site_id.setText(site.site_id)
|
||||
self.repeats.setValue(site.repeats)
|
||||
if site.uv_px is not None:
|
||||
self.host.u_spin.setValue(int(round(site.uv_px[0])))
|
||||
self.host.v_spin.setValue(int(round(site.uv_px[1])))
|
||||
|
||||
def _refresh(self) -> None:
|
||||
finished = {item["site_id"]: item for item in self.campaign.completion()}
|
||||
listed = tuple(
|
||||
(
|
||||
site.site_id, site.x, site.y, site.z, site.levels_n, site.hold_s, site.repeats,
|
||||
finished[site.site_id]["repeats_complete"],
|
||||
)
|
||||
for site in self.campaign.sites
|
||||
)
|
||||
if listed != self._listed:
|
||||
self._listed = listed
|
||||
self.table.setRowCount(len(self.campaign.sites))
|
||||
for row, site in enumerate(self.campaign.sites):
|
||||
item = finished[site.site_id]
|
||||
values = [
|
||||
site.site_id, f"{site.x:g}", f"{site.y:g}", f"{site.z:g}",
|
||||
", ".join(f"{value:g}" for value in site.levels_n),
|
||||
f"{site.hold_s:g}", str(site.repeats),
|
||||
f"{item['repeats_complete']}/{item['repeats_planned']}",
|
||||
]
|
||||
for column, value in enumerate(values):
|
||||
self.table.setItem(row, column, QTableWidgetItem(value))
|
||||
active = self.campaign.is_active
|
||||
busy = self.campaign.busy
|
||||
wrench = self.source.currentData() == "wrench"
|
||||
self.wrench_box.setVisible(wrench)
|
||||
for widget in (
|
||||
self.add_button, self.update_button, self.remove_button, self.up_button,
|
||||
self.down_button, self.simulate_button, self.start_button,
|
||||
self.site_id, self.repeats, self.frame, self.max_force, self.min_force, self.source,
|
||||
):
|
||||
widget.setEnabled(not busy)
|
||||
for widget in (self.fx_edit, self.fy_edit, self.fz_edit, self.rotation):
|
||||
widget.setEnabled(self.campaign.phase == "load" and wrench)
|
||||
self.liftoff_button.setEnabled(self.campaign.phase == "liftoff")
|
||||
self.remeasure_button.setEnabled(self.campaign.can_remeasure_step())
|
||||
self.stop_button.setEnabled(active)
|
||||
self.reacquire_button.setEnabled(
|
||||
not busy and bool(self._selected) and self.campaign.directory is not None
|
||||
)
|
||||
state = self.campaign.progress()
|
||||
next_id = state["site_id"] if self.campaign.phase == "liftoff" else ""
|
||||
if next_id and next_id != self._offered_liftoff:
|
||||
self._offered_liftoff = next_id
|
||||
for row, site in enumerate(self.campaign.sites):
|
||||
if site.site_id == next_id:
|
||||
self.table.selectRow(row)
|
||||
break
|
||||
elif self.campaign.phase != "liftoff":
|
||||
self._offered_liftoff = ""
|
||||
self.align_button.setEnabled(self._can_align())
|
||||
branch = {
|
||||
"baseline": t("multipoint.branch_baseline"),
|
||||
"load": t("multipoint.branch_load"),
|
||||
"unload": t("multipoint.branch_unload"),
|
||||
}.get(state["branch"], state["branch"])
|
||||
if self.campaign.phase == "liftoff":
|
||||
self.progress.setText(t("multipoint.liftoff_wait").format(
|
||||
site=state["site_id"], done=state["done"], total=state["total"],
|
||||
))
|
||||
elif state["phase"] in {"load", "hold"}:
|
||||
self.progress.setText(t("multipoint.progress").format(
|
||||
site=state["site_id"], site_index=state["site_index"],
|
||||
site_count=state["site_count"], branch=branch,
|
||||
load=state["target_n"], repeat=state["repeat_index"],
|
||||
repeats=state["repeats"], done=state["done"], total=state["total"],
|
||||
seconds=self.campaign.remaining_s(),
|
||||
))
|
||||
else:
|
||||
self.progress.setText(t("multipoint.idle"))
|
||||
shown = next(
|
||||
(site for site in self.campaign.sites if site.site_id == (state["site_id"] or self._selected)),
|
||||
None,
|
||||
)
|
||||
pose = self._pose_for_display(shown)
|
||||
if pose:
|
||||
text = t("multipoint.pose").format(
|
||||
x=pose["x"], y=pose["y"], yaw=pose["yaw"], pitch=pose["pitch"],
|
||||
z=pose["z"], residual=float(pose.get("residual_deg") or 0.0),
|
||||
)
|
||||
if not pose.get("in_range"):
|
||||
text += "\n" + t("multipoint.pose_refused").format(reason=pose.get("reason") or "")
|
||||
self.pose_status.setText(text)
|
||||
else:
|
||||
self.pose_status.setText(t("multipoint.no_pose"))
|
||||
if self.campaign.phase == "done":
|
||||
save = t("multipoint.save_done")
|
||||
elif self.campaign.phase == "aborted":
|
||||
save = t("multipoint.save_kept")
|
||||
elif active or self.campaign.closing:
|
||||
save = t("multipoint.save_writing")
|
||||
else:
|
||||
save = t("multipoint.save_idle")
|
||||
self.save_status.setText(t("multipoint.save").format(status=save, path=state["directory"] or "—"))
|
||||
self.capture_status.setText(self._capture_text(state))
|
||||
|
||||
def _capture_text(self, state: dict) -> str:
|
||||
"""Where the recording is, and why it is not complete, in one line."""
|
||||
capture = state.get("capture") or {}
|
||||
status = capture.get("state") or "idle"
|
||||
key = {
|
||||
"idle": "multipoint.capture_idle",
|
||||
"recording": "multipoint.capture_recording",
|
||||
"closing": "multipoint.capture_closing",
|
||||
"complete": "multipoint.capture_complete",
|
||||
"incomplete": "multipoint.capture_incomplete",
|
||||
"not_recorded": "multipoint.capture_not_recorded",
|
||||
}.get(status, "multipoint.capture_idle")
|
||||
reason = capture.get("reason") or ""
|
||||
reason_key = {
|
||||
"source_restarted": "multipoint.capture_reason_restarted",
|
||||
"zero_frames": "multipoint.capture_reason_zero_frames",
|
||||
"write_error": "multipoint.capture_reason_write_error",
|
||||
"video_missing": "multipoint.capture_reason_video_missing",
|
||||
"video_undecodable": "multipoint.capture_reason_video_undecodable",
|
||||
"dropped_data": "multipoint.capture_reason_dropped",
|
||||
"dropped_video": "multipoint.capture_reason_dropped",
|
||||
"segment_count": "multipoint.capture_reason_segments",
|
||||
"closing_timeout": "multipoint.capture_reason_timeout",
|
||||
"source_stopped": "multipoint.capture_reason_source_stopped",
|
||||
"not_recorded": "multipoint.capture_reason_not_recorded",
|
||||
}.get(reason)
|
||||
return t("multipoint.capture").format(
|
||||
status=t(key),
|
||||
frames=int(capture.get("frames") or 0),
|
||||
dropped=int(capture.get("dropped_data") or 0) + int(capture.get("dropped_video") or 0),
|
||||
reason=t(reason_key) if reason_key else (reason or "—"),
|
||||
)
|
||||
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..record_overview import load_overview, overview_rows, read_plan, save_overview
|
||||
from ..recording_options import RecordingOptions
|
||||
from ..session_review import review_session
|
||||
from ..record_diagnostics import trial_diagnostics
|
||||
from ..sensor.uv_outline import recorded_outline
|
||||
@@ -255,7 +256,9 @@ class RecordOverviewDialog(QDialog):
|
||||
model=self.review.get("sensor_model") or "—", sn=self.review.get("sensor_sn") or "—",
|
||||
date=date or "—", source=" / ".join(t("overview.source_" + s) for s in sources) or "—",
|
||||
status=t("overview.capture_in_progress") if self.review["session_status"] == "in_progress"
|
||||
else t("calibration." + self.review["session_status"])))
|
||||
else t("calibration." + self.review["session_status"]))
|
||||
+ ("" if RecordingOptions.from_block(self.review.get("recording")).video
|
||||
else "\n" + t("overview.video_not_recorded")))
|
||||
self.identity.setToolTip(self.review["session_id"] + "\n" + str(self.directory))
|
||||
selected = self.table.currentRow()
|
||||
self.rows = overview_rows(self.review, self.plan)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Open a file or folder in the platform's file manager.
|
||||
|
||||
One implementation for the menu's "show config" actions and for the capture page's
|
||||
session row: the operator has the recording path in front of them and wants the
|
||||
folder, not a path string to paste. Deliberately Qt-free — it hands the path to
|
||||
the OS, and a headless test can assert the exact argument the file manager gets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def reveal(path: str | Path | None, select: str | Path | None = None) -> bool:
|
||||
"""Show ``path`` in the platform file manager; return whether it launched.
|
||||
|
||||
On Windows a ``select`` names a file to highlight, which is the
|
||||
``explorer /select,<file>`` form. Explorer expects that switch and the quoted
|
||||
path as one command-line string; passing the two as separate argv entries
|
||||
breaks on a path containing a space. When the selected file has gone since
|
||||
the operator clicked, the containing folder is opened instead of failing
|
||||
silently.
|
||||
|
||||
A missing path, an empty path or a failed launch returns ``False`` and is
|
||||
logged: this is a convenience action, never a reason to raise into the event
|
||||
loop that called it.
|
||||
"""
|
||||
if path is None or not str(path).strip():
|
||||
return False
|
||||
target = Path(path)
|
||||
if not target.exists():
|
||||
logger.warning("Cannot reveal a path that does not exist: %s", target)
|
||||
return False
|
||||
folder = target if target.is_dir() else target.parent
|
||||
selected = None if select is None else Path(select)
|
||||
if selected is not None and not selected.exists():
|
||||
logger.warning("The file to select is gone; opening its folder instead: %s", selected)
|
||||
selected = None
|
||||
command: str | list[str]
|
||||
if sys.platform == "win32":
|
||||
if selected is not None:
|
||||
command = f'explorer /select,"{selected}"'
|
||||
else:
|
||||
command = ["explorer", str(folder)]
|
||||
elif sys.platform == "darwin": # pragma: no cover - Windows is the target
|
||||
command = ["open", str(folder)]
|
||||
else: # pragma: no cover - Windows is the target
|
||||
command = ["xdg-open", str(folder)]
|
||||
try:
|
||||
subprocess.Popen(command)
|
||||
except OSError:
|
||||
logger.exception("Could not open %s", folder)
|
||||
return False
|
||||
return True
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Finger debug screen: pick a calibration point on the flat image, get a pose.
|
||||
"""Geometry and mounting screen: pick one calibration point on the flat image.
|
||||
|
||||
The page used to keep a table of picked points and export it. The queue on the
|
||||
capture page owns points now, so this screen is the single-point trial it always
|
||||
was underneath: pick a point, read the geometry, watch the simulated pose and the
|
||||
3D reach preview, and optionally run that one pose.
|
||||
|
||||
Workflow, with no platform connected:
|
||||
|
||||
@@ -18,8 +23,6 @@ not.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -29,17 +32,13 @@ from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -49,10 +48,7 @@ from ..motion.pose_move import PressAxis, press_targets
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import describe as describe_frames
|
||||
from ..sensor.frames import machine_table_xy
|
||||
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
|
||||
|
||||
#: How many picked points the list keeps before the oldest is dropped.
|
||||
MAX_POINTS = 200
|
||||
from ..sensor.uv_flat import FlatSurface, uv_origin
|
||||
|
||||
#: How close a stored point must be to a raster entry to be shown on the flat
|
||||
#: image. One pixel of the 1024-wide raster is about 0.0147 mm on this export, so
|
||||
@@ -223,31 +219,23 @@ class FlatSurfaceView(QLabel):
|
||||
|
||||
|
||||
class FingerPanel(QGroupBox):
|
||||
"""The whole debug screen: bundle, picker, readouts, point list, preview."""
|
||||
"""Geometry and mounting: bundle, single-point picker, readouts and preview.
|
||||
|
||||
The panel keeps exactly one picked point. A queue of points belongs to the
|
||||
capture page; this screen is where one point is tried, and where the mounting
|
||||
numbers that turn a sensor-frame point into a platform pose are entered.
|
||||
"""
|
||||
|
||||
loadRequested = Signal()
|
||||
pickRequested = Signal(int, int)
|
||||
stackChanged = Signal()
|
||||
#: Run the selected point's solved pose on the platform, and stop moving.
|
||||
#: Run the picked point's solved pose on the platform, and stop moving.
|
||||
executeRequested = Signal()
|
||||
stopRequested = Signal()
|
||||
#: Whether to ask before moving. Persisted; the window honours it for every
|
||||
#: pose move, including the rail's return-to-zero.
|
||||
confirmChanged = Signal(bool)
|
||||
|
||||
#: Columns of the point table, minus the leading select checkbox. The first six
|
||||
#: are the ones an operator may edit — the sensor-frame point and its normal —
|
||||
#: and everything after them is *solved*, so it is redrawn from the edit rather
|
||||
#: than typed into.
|
||||
COLUMNS: tuple[str, ...] = (
|
||||
"x", "y", "z", "nx", "ny", "nz",
|
||||
"X", "Y", "Z", "yaw", "pitch", "residual", "u_px", "v_px",
|
||||
)
|
||||
#: How many of ``COLUMNS`` are inputs rather than solved outputs.
|
||||
INPUT_COLUMNS = 6
|
||||
#: Offset of the first data column; column 0 holds the select checkbox.
|
||||
SELECT_COLUMN = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stack: PlatformStack | None = None,
|
||||
@@ -262,13 +250,8 @@ class FingerPanel(QGroupBox):
|
||||
self.setObjectName("groupBox")
|
||||
self._stack = stack or PlatformStack()
|
||||
self._bundle: SensorBundle | None = None
|
||||
#: Full-precision picked rows, index-aligned with the table.
|
||||
self._picked: list[dict[str, float]] = []
|
||||
#: True while this panel is writing the table itself, so the ``itemChanged``
|
||||
#: handler can tell its own redraw from an operator's edit. Without it every
|
||||
#: solved-column update would be re-read as an edit and re-solve.
|
||||
self._writing = False
|
||||
self._headers = self.COLUMNS
|
||||
#: The one picked point, at full precision. ``None`` until a pick lands.
|
||||
self._point: dict[str, float] | None = None
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(10, 8, 10, 10)
|
||||
@@ -289,20 +272,9 @@ class FingerPanel(QGroupBox):
|
||||
column.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
body.addWidget(picker_box, 2)
|
||||
body.addWidget(readout_box, 3)
|
||||
body.addWidget(preview_box, 5)
|
||||
# The body outranks the table, because the 3D view is the pane an operator
|
||||
# actually reads the pose from, and at an even split it came out 240 px tall
|
||||
# — a box too short to judge a normal in, under three lines of legend.
|
||||
body.addWidget(preview_box, 6)
|
||||
outer.addLayout(body, 1)
|
||||
|
||||
# ``Maximum`` so the table stops at its natural height and the spare vertical
|
||||
# space goes to the 3D view above it. Left expandable it ate the window: the
|
||||
# preview stayed at its 240 px floor while the table had hundreds of empty
|
||||
# pixels under three rows.
|
||||
table_box = self._build_point_table()
|
||||
table_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
outer.addWidget(table_box)
|
||||
|
||||
self.set_sensor_setup(sensor_offset_mm, sensor_yaw_deg)
|
||||
self.set_tool_xy(*tool_xy_mm)
|
||||
self.retranslate()
|
||||
@@ -582,62 +554,6 @@ class FingerPanel(QGroupBox):
|
||||
self._preview_box = box
|
||||
return box
|
||||
|
||||
def _build_point_table(self) -> QWidget:
|
||||
box = QGroupBox(self)
|
||||
box.setObjectName("groupBox")
|
||||
column = QVBoxLayout(box)
|
||||
column.setContentsMargins(8, 6, 8, 8)
|
||||
column.setSpacing(6)
|
||||
|
||||
self.table = QTableWidget(0, len(self._headers) + 1, box)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||
# Editable, but only on a deliberate double-click: a single click selects
|
||||
# the row (and drives the markers), which is what an operator does most.
|
||||
self.table.setEditTriggers(
|
||||
QTableWidget.EditTrigger.DoubleClicked
|
||||
| QTableWidget.EditTrigger.EditKeyPressed
|
||||
)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setColumnWidth(0, 34)
|
||||
for index in range(1, len(self._headers) + 1):
|
||||
self.table.setColumnWidth(index, 82)
|
||||
self.table.horizontalHeader().setSectionResizeMode(
|
||||
len(self._headers), QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self.table.setMinimumHeight(90)
|
||||
self.table.itemChanged.connect(self._on_item_changed)
|
||||
self.table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
column.addWidget(self.table, 1)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.clear_button = QPushButton(box)
|
||||
self.clear_button.clicked.connect(self.clear_points)
|
||||
self.remove_button = QPushButton(box)
|
||||
self.remove_button.clicked.connect(self.remove_selected)
|
||||
self.up_button = QPushButton(box)
|
||||
self.up_button.clicked.connect(lambda: self.move_selected(-1))
|
||||
self.down_button = QPushButton(box)
|
||||
self.down_button.clicked.connect(lambda: self.move_selected(1))
|
||||
self.export_sites_button = QPushButton(box)
|
||||
self.export_sites_button.setObjectName("exportCalibrationSites")
|
||||
self.export_sites_button.clicked.connect(self._export_sites_dialog)
|
||||
for button in (self.clear_button, self.remove_button, self.up_button, self.down_button):
|
||||
row.addWidget(button)
|
||||
row.addWidget(self.export_sites_button)
|
||||
row.addStretch(1)
|
||||
self._selected_label = QLabel(box)
|
||||
self._selected_label.setObjectName("hintLabel")
|
||||
row.addWidget(self._selected_label)
|
||||
self.count_label = QLabel(box)
|
||||
self.count_label.setObjectName("hintLabel")
|
||||
row.addWidget(self.count_label)
|
||||
column.addLayout(row)
|
||||
|
||||
self._table_box = box
|
||||
return box
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@property
|
||||
@@ -699,16 +615,25 @@ class FingerPanel(QGroupBox):
|
||||
self.preview.set_tool_xy(*self.tool_xy_mm)
|
||||
|
||||
def _resolve_everything(self) -> None:
|
||||
"""Re-solve every stored row, then redraw what the current one shows.
|
||||
"""The setup or the tool moved: redraw what the picked point now solves to.
|
||||
|
||||
Every row, not only the current one: the setup and the tool move the whole
|
||||
machine, so a table still holding the old X/Y/Z would be a table of numbers
|
||||
from a machine that no longer exists.
|
||||
The capture page's queue is re-solved by that page when this signal fires;
|
||||
this screen only owns its single picked point, so it re-solves that one and
|
||||
repaints both views with the machine that exists now.
|
||||
"""
|
||||
for row in range(len(self._picked)):
|
||||
self.resolve_row(row)
|
||||
self._after_points_changed()
|
||||
self._refresh_path()
|
||||
record = self._point
|
||||
if record is not None:
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
pose = self._stack.solve(point, normal)
|
||||
self._last_pose = pose
|
||||
self.preview.set_stack(self._stack)
|
||||
self.preview.set_point(point, normal)
|
||||
self.preview.set_pose(pose)
|
||||
self.preview.set_highlight(point)
|
||||
self._sync_sim_button()
|
||||
self.refresh_readouts(point, normal, pose)
|
||||
self._refresh_path()
|
||||
self._refresh_reach()
|
||||
self.stackChanged.emit()
|
||||
|
||||
@@ -716,6 +641,9 @@ class FingerPanel(QGroupBox):
|
||||
self.path_edit.setText(path)
|
||||
|
||||
def set_bundle(self, bundle: SensorBundle | None, flat: FlatSurface | None = None) -> None:
|
||||
# A different mesh means a previously picked point names a surface that no
|
||||
# longer exists, so the single-point trial starts over.
|
||||
self.clear_point()
|
||||
self._bundle = bundle
|
||||
self.view.set_flat(flat)
|
||||
self.preview.set_bundle(bundle)
|
||||
@@ -836,110 +764,30 @@ class FingerPanel(QGroupBox):
|
||||
else:
|
||||
self.view_label.setText(t("finger.untextured"))
|
||||
|
||||
def clear_points(self) -> None:
|
||||
self._picked.clear()
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setRowCount(0)
|
||||
finally:
|
||||
self._writing = False
|
||||
def clear_point(self) -> None:
|
||||
"""Forget the picked point, and the pictures drawn for it."""
|
||||
self._point = None
|
||||
self._last_pose = None
|
||||
self._last_face = -1
|
||||
self.preview.set_pose(None)
|
||||
self.preview.set_point(None, None)
|
||||
self.preview.set_highlight(None)
|
||||
self.view.set_marker(None)
|
||||
self.view.set_selected(None)
|
||||
self._sync_sim_button()
|
||||
self._refresh_count()
|
||||
self.path_label.setText("")
|
||||
|
||||
def point_count(self) -> int:
|
||||
return self.table.rowCount()
|
||||
return 0 if self._point is None else 1
|
||||
|
||||
def points(self) -> list[dict[str, float]]:
|
||||
"""Every picked point, at full precision, for a later export."""
|
||||
return [dict(row) for row in self._picked]
|
||||
"""The single picked point, at full precision (empty before a pick).
|
||||
|
||||
def acquisition_points(self) -> list[dict]:
|
||||
"""Picked points with a freshly solved pose for 02 acquisition.
|
||||
|
||||
The pose is the same ``PlatformStack.solve`` result the execute button uses.
|
||||
Calling this does not move the platform.
|
||||
A list, not a bare dict, because the old table-based callers asked for a
|
||||
sequence; this screen tries one point and the capture page's queue is where
|
||||
many are kept.
|
||||
"""
|
||||
points = []
|
||||
for index, record in enumerate(self._picked, 1):
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
pose = self._stack.solve(point, normal)
|
||||
move_x, move_y = machine_table_xy(pose.table_xy_mm)
|
||||
points.append({
|
||||
"site_id": f"site-{index:03d}",
|
||||
"x": float(point[0]),
|
||||
"y": float(point[1]),
|
||||
"z": float(point[2]),
|
||||
"nx": float(normal[0]),
|
||||
"ny": float(normal[1]),
|
||||
"nz": float(normal[2]),
|
||||
"u_px": record.get("u_px"),
|
||||
"v_px": record.get("v_px"),
|
||||
"pose": {
|
||||
"x": float(move_x),
|
||||
"y": float(move_y),
|
||||
"yaw": float(pose.yaw_deg),
|
||||
"pitch": float(pose.pitch_deg),
|
||||
"z": float(pose.contact_z_mm),
|
||||
"residual_deg": float(pose.residual_deg),
|
||||
"upright": bool(pose.upright),
|
||||
"in_range": bool(pose.ok and pose.in_range),
|
||||
"ok": bool(pose.ok),
|
||||
"normal_aligned_to": f"tool_axis:{self._stack.tool_axis}",
|
||||
"reason": pose.reason,
|
||||
},
|
||||
})
|
||||
return points
|
||||
|
||||
def export_sites(self, path: str | Path) -> Path:
|
||||
"""Export the displayed order, with the same UV convention as calibration."""
|
||||
flat = self.view.flat
|
||||
if flat is None or not self._picked:
|
||||
raise ValueError(t("finger.export_no_points"))
|
||||
origin_x, origin_y = uv_origin(flat)
|
||||
identity = mapping_sha256(flat)
|
||||
rows = []
|
||||
for order, record in enumerate(self._picked, 1):
|
||||
site = site_at(
|
||||
flat, record["u_px"], record["v_px"], origin=(origin_x, origin_y),
|
||||
mapping_digest=identity,
|
||||
)
|
||||
point = [record[key] for key in ("x", "y", "z")]
|
||||
if not np.allclose(point, site.position_sensor_mm, rtol=0, atol=1e-9):
|
||||
raise ValueError(t("finger.export_edited_point").format(order=order))
|
||||
rows.append({
|
||||
"order": order, "site_id": f"site-{order:03d}",
|
||||
"u_px": site.u_px, "v_px": site.v_px,
|
||||
**{f"{axis}_sensor_mm": record[axis] for axis in ("x", "y", "z")},
|
||||
**{f"{axis.lower()}_platform_mm": record[axis] for axis in ("X", "Y", "Z")},
|
||||
"yaw_deg": record["yaw"], "pitch_deg": record["pitch"],
|
||||
"origin_raster_x_px": origin_x, "origin_raster_y_px": origin_y,
|
||||
"raster_width_px": flat.width, "raster_height_px": flat.height,
|
||||
"u_positive": "right", "v_positive": "up", "mapping_sha256": identity,
|
||||
})
|
||||
destination = Path(path)
|
||||
with destination.open("w", newline="", encoding="utf-8-sig") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return destination
|
||||
|
||||
def _export_sites_dialog(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, t("finger.export_sites"), "calibration-sites.csv", "CSV (*.csv)"
|
||||
)
|
||||
if path:
|
||||
try:
|
||||
self.export_sites(path)
|
||||
self.status_label.setText(t("finger.sites_exported").format(path=path))
|
||||
except (OSError, ValueError) as exc:
|
||||
self.status_label.setText(t("calibration.error").format(detail=exc))
|
||||
return [] if self._point is None else [dict(self._point)]
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
@@ -947,7 +795,8 @@ class FingerPanel(QGroupBox):
|
||||
"""Turn a click into a pose. Returns the row, or ``None`` if no surface.
|
||||
|
||||
``None`` is the honest answer for a UV-island gap: reporting (0, 0, 0)
|
||||
would put a calibration point at the sensor's origin.
|
||||
would put a calibration point at the sensor's origin. A second click
|
||||
replaces the first: this screen tries one point at a time.
|
||||
"""
|
||||
if self.view.flat is None:
|
||||
self.status_label.setText(t("finger.no_bundle"))
|
||||
@@ -960,9 +809,10 @@ class FingerPanel(QGroupBox):
|
||||
point, normal, face = sample
|
||||
origin_x, origin_y = uv_origin(self.view.flat)
|
||||
pose = self._stack.solve(point, normal)
|
||||
# The click leaves a cross-hair where it landed *and* promotes the new row
|
||||
# to the selection, so the red dot marks the same point in both views.
|
||||
# The click leaves a cross-hair where it landed and a red dot on the point
|
||||
# about to be tried, so both views name the same pixel.
|
||||
self.view.set_marker((x, y))
|
||||
self.view.set_selected((x, y))
|
||||
self.preview.set_stack(self._stack)
|
||||
self.preview.set_point(point, normal)
|
||||
|
||||
@@ -975,7 +825,7 @@ class FingerPanel(QGroupBox):
|
||||
"residual": float(pose.residual_deg),
|
||||
"u_px": x - origin_x, "v_px": origin_y - y,
|
||||
}
|
||||
self._append_row(row)
|
||||
self._point = dict(row)
|
||||
self._last_pose = pose
|
||||
self._last_face = face
|
||||
# Hand the solved pose to the preview so the play button demonstrates
|
||||
@@ -985,266 +835,8 @@ class FingerPanel(QGroupBox):
|
||||
self._sync_sim_button()
|
||||
self.refresh_readouts(point, normal, pose, face)
|
||||
self._refresh_path()
|
||||
# Select the row just added, so the table agrees with the dot.
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setCurrentCell(self.table.rowCount() - 1, 1)
|
||||
finally:
|
||||
self._writing = False
|
||||
return row
|
||||
|
||||
def _append_row(self, row: dict[str, float]) -> None:
|
||||
# The full-precision row is kept alongside the table, because the table
|
||||
# only holds three decimals for reading. Recovering the numbers by parsing
|
||||
# the displayed text would quietly hand a later export coordinates the
|
||||
# solver never produced.
|
||||
self._picked.append(dict(row))
|
||||
self.table.insertRow(self.table.rowCount())
|
||||
self._write_row(self.table.rowCount() - 1)
|
||||
while self.table.rowCount() > MAX_POINTS:
|
||||
self.table.removeRow(0)
|
||||
while len(self._picked) > MAX_POINTS:
|
||||
self._picked.pop(0)
|
||||
self._refresh_count()
|
||||
|
||||
# ------------------------------------------------------- table row plumbing
|
||||
|
||||
def _write_row(self, index: int) -> None:
|
||||
"""Render one ``_picked`` row into the table, without re-triggering edits."""
|
||||
if not (0 <= index < len(self._picked)):
|
||||
return
|
||||
row = self._picked[index]
|
||||
self._writing = True
|
||||
try:
|
||||
for offset, key in enumerate(self._headers):
|
||||
column = offset + 1
|
||||
item = self.table.item(index, column)
|
||||
if item is None:
|
||||
item = QTableWidgetItem()
|
||||
item.setTextAlignment(
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
if offset >= self.INPUT_COLUMNS:
|
||||
# Solved values are shown, not typed: editing the normal and
|
||||
# reading the pose is the workflow, and the other way round
|
||||
# would need an inverse the table cannot express.
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
item.setForeground(QColor("#5c6678"))
|
||||
self.table.setItem(index, column, item)
|
||||
item.setText(
|
||||
f"{row[key]:.0f}" if key in {"u_px", "v_px"} else f"{row[key]:.3f}"
|
||||
)
|
||||
self._ensure_selector(index)
|
||||
finally:
|
||||
self._writing = False
|
||||
|
||||
def _ensure_selector(self, index: int, checked: bool = False) -> None:
|
||||
"""Give a row its select checkbox, created once."""
|
||||
if self.table.cellWidget(index, self.SELECT_COLUMN) is not None:
|
||||
return
|
||||
holder = QWidget(self.table)
|
||||
layout = QHBoxLayout(holder)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
check = QCheckBox(holder)
|
||||
check.setChecked(checked)
|
||||
check.toggled.connect(self._refresh_count)
|
||||
layout.addWidget(check)
|
||||
self.table.setCellWidget(index, self.SELECT_COLUMN, holder)
|
||||
|
||||
def _selector(self, index: int) -> QCheckBox | None:
|
||||
holder = self.table.cellWidget(index, self.SELECT_COLUMN)
|
||||
if holder is None:
|
||||
return None
|
||||
return holder.findChild(QCheckBox)
|
||||
|
||||
def selected_rows(self) -> list[int]:
|
||||
"""Rows whose checkbox is ticked, ascending."""
|
||||
return [
|
||||
index
|
||||
for index in range(self.table.rowCount())
|
||||
if (check := self._selector(index)) is not None and check.isChecked()
|
||||
]
|
||||
|
||||
def remove_selected(self) -> int:
|
||||
"""Drop every ticked row, and the last-picked point if none is ticked."""
|
||||
rows = self.selected_rows()
|
||||
if not rows:
|
||||
row = self.table.currentRow()
|
||||
rows = [row] if row >= 0 else []
|
||||
for index in sorted(rows, reverse=True):
|
||||
self.table.removeRow(index)
|
||||
if 0 <= index < len(self._picked):
|
||||
self._picked.pop(index)
|
||||
self._refresh_count()
|
||||
self._after_points_changed()
|
||||
return len(rows)
|
||||
|
||||
def move_selected(self, offset: int) -> bool:
|
||||
"""Shift the ticked rows (or the current one) up or down one place.
|
||||
|
||||
Moves the topmost row first when going down and the bottom-most first when
|
||||
going up, so a contiguous block keeps its internal order and no row is
|
||||
swapped twice. The current row follows the move, so repeating the action
|
||||
keeps pushing the same point rather than stranding the selection.
|
||||
"""
|
||||
rows = self.selected_rows() or (
|
||||
[self.table.currentRow()] if self.table.currentRow() >= 0 else []
|
||||
)
|
||||
if not rows:
|
||||
return False
|
||||
ordered = sorted(rows, reverse=offset > 0)
|
||||
moved = False
|
||||
landed = self.table.currentRow()
|
||||
for index in ordered:
|
||||
target = index + offset
|
||||
if not (0 <= target < self.table.rowCount()):
|
||||
continue
|
||||
if target in rows: # a selected neighbour will move too; do not swap twice
|
||||
continue
|
||||
self._swap_rows(index, target)
|
||||
if index == self.table.currentRow():
|
||||
landed = target
|
||||
moved = True
|
||||
if moved:
|
||||
self._writing = True
|
||||
try:
|
||||
self.table.setCurrentCell(landed, max(1, self.table.currentColumn()))
|
||||
finally:
|
||||
self._writing = False
|
||||
self._refresh_count()
|
||||
self._after_points_changed()
|
||||
return moved
|
||||
|
||||
def _swap_rows(self, first: int, second: int) -> None:
|
||||
"""Exchange two table rows and their stored points, selection included."""
|
||||
self._picked[first], self._picked[second] = self._picked[second], self._picked[first]
|
||||
checked = [
|
||||
bool((check := self._selector(row)) is not None and check.isChecked())
|
||||
for row in (first, second)
|
||||
]
|
||||
self._writing = True
|
||||
try:
|
||||
# Rebuild both rows from the swapped data: simpler than moving items, and
|
||||
# it cannot leave a stale value behind. ``takeCellWidget`` does not exist,
|
||||
# so the old checkboxes are detached by replacing them with None.
|
||||
for row in (first, second):
|
||||
for column in range(1, len(self._headers) + 1):
|
||||
self.table.takeItem(row, column)
|
||||
self.table.removeCellWidget(row, self.SELECT_COLUMN)
|
||||
self._write_row(first)
|
||||
self._write_row(second)
|
||||
finally:
|
||||
self._writing = False
|
||||
for row, was_checked in zip((first, second), checked):
|
||||
check = self._selector(row)
|
||||
if check is not None:
|
||||
check.setChecked(was_checked)
|
||||
|
||||
def _refresh_count(self) -> None:
|
||||
total = self.table.rowCount()
|
||||
chosen = len(self.selected_rows())
|
||||
self.count_label.setText(t("finger.point_count", count=total))
|
||||
self.remove_button.setEnabled(total > 0)
|
||||
self.up_button.setEnabled(total > 0)
|
||||
self.down_button.setEnabled(total > 0)
|
||||
self._selected_label.setText(
|
||||
t("finger.selected_count", count=chosen) if chosen else ""
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------- editing
|
||||
|
||||
def _on_item_changed(self, item: QTableWidgetItem) -> None:
|
||||
"""An operator edited a cell: re-solve the row and refresh everything.
|
||||
|
||||
Only the six input columns are editable, so an edit always means "this
|
||||
sensor-frame point or normal changed". The pose columns are redrawn from the
|
||||
new solve rather than trusted, which is what keeps the table and the readouts
|
||||
from disagreeing.
|
||||
"""
|
||||
if self._writing:
|
||||
return
|
||||
column = item.column() - 1
|
||||
row = item.row()
|
||||
if column < 0 or column >= self.INPUT_COLUMNS:
|
||||
return
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
try:
|
||||
value = float(item.text())
|
||||
except ValueError:
|
||||
# Put the old number back rather than storing a non-number.
|
||||
self._write_row(row)
|
||||
self.status_label.setText(t("finger.edit_not_a_number"))
|
||||
return
|
||||
if not math.isfinite(value):
|
||||
self._write_row(row)
|
||||
self.status_label.setText(t("finger.edit_not_a_number"))
|
||||
return
|
||||
|
||||
record = self._picked[row]
|
||||
record[self._headers[column]] = value
|
||||
self.resolve_row(row)
|
||||
self._after_points_changed()
|
||||
|
||||
def resolve_row(self, row: int) -> None:
|
||||
"""Re-solve a stored row from its sensor-frame point and normal."""
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
record = self._picked[row]
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
length = float(np.linalg.norm(normal))
|
||||
if length < 1e-12:
|
||||
return
|
||||
normal = normal / length
|
||||
pose = self._stack.solve(point, normal)
|
||||
record.update(
|
||||
{
|
||||
"nx": float(normal[0]), "ny": float(normal[1]), "nz": float(normal[2]),
|
||||
"X": float(pose.target_xy_mm[0]), "Y": float(pose.target_xy_mm[1]),
|
||||
"Z": float(pose.contact_z_mm),
|
||||
"yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg),
|
||||
"residual": float(pose.residual_deg),
|
||||
}
|
||||
)
|
||||
self._write_row(row)
|
||||
|
||||
def _on_selection_changed(self) -> None:
|
||||
"""Follow the current row in both pictures, so the marker means something."""
|
||||
row = self.table.currentRow()
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
self.show_point(row)
|
||||
|
||||
def show_point(self, row: int) -> None:
|
||||
"""Point the 2D and 3D markers at one row: this is the point to be tested."""
|
||||
if not (0 <= row < len(self._picked)):
|
||||
return
|
||||
record = self._picked[row]
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
self.preview.set_point(point, normal)
|
||||
self.preview.set_highlight(point)
|
||||
if self.view.flat is not None:
|
||||
pixel = self.view.pixel_for_point(point)
|
||||
self.view.set_selected(pixel)
|
||||
pose = self._stack.solve(point, normal)
|
||||
self._last_pose = pose
|
||||
self.preview.set_pose(pose)
|
||||
self._sync_sim_button()
|
||||
self.refresh_readouts(point, normal, pose)
|
||||
self._refresh_path()
|
||||
|
||||
def _after_points_changed(self) -> None:
|
||||
"""Keep the picture honest after a delete, reorder or edit."""
|
||||
row = self.table.currentRow()
|
||||
if 0 <= row < len(self._picked):
|
||||
self.show_point(row)
|
||||
else:
|
||||
self.preview.set_highlight(None)
|
||||
self.view.set_selected(None)
|
||||
|
||||
# ----------------------------------------------------------------- readouts
|
||||
|
||||
_last_pose = None
|
||||
@@ -1417,7 +1009,6 @@ class FingerPanel(QGroupBox):
|
||||
self._picker_box.setTitle(t("finger.flat_title"))
|
||||
self._readout_box.setTitle(t("finger.readout_title"))
|
||||
self._preview_box.setTitle(t("finger.preview_title"))
|
||||
self._table_box.setTitle(t("finger.points_title"))
|
||||
labels = {
|
||||
"sensor_point": "finger.row_sensor_point",
|
||||
"sensor_normal": "finger.row_sensor_normal",
|
||||
@@ -1456,16 +1047,5 @@ class FingerPanel(QGroupBox):
|
||||
self.stop_button.setToolTip(t("move.stop_hint"))
|
||||
self.confirm_check.setText(t("move.confirm_check"))
|
||||
self.confirm_check.setToolTip(t("move.confirm_hint"))
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
||||
)
|
||||
self.clear_button.setText(t("finger.clear_points"))
|
||||
self.export_sites_button.setText(t("finger.export_sites"))
|
||||
self.export_sites_button.setToolTip(t("finger.export_sites_tip"))
|
||||
self.remove_button.setText(t("finger.remove_selected"))
|
||||
self.up_button.setText(t("finger.move_up"))
|
||||
self.down_button.setText(t("finger.move_down"))
|
||||
self.table.setToolTip(t("finger.table_hint"))
|
||||
self._refresh_count()
|
||||
|
||||
self.status_label.setText(t("finger.select_hint"))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""The operator's point sheet: one CSV row per queue point.
|
||||
|
||||
This is the file an operator reproduces by hand at the UV map: the pixel to click,
|
||||
the sensor-frame position behind it, the platform target the simulated mounting
|
||||
produces, and the mapping identity that makes the pixel coordinates reproducible.
|
||||
It is an *operation sheet*, not an executed sequence.
|
||||
|
||||
The field list is one definition because two readers depend on it: the exporter
|
||||
below and the test that checks it against
|
||||
``apps/autocal/docs/calibration-data-format.zh.md``. A second copy of the names is
|
||||
how the table and the document drift apart.
|
||||
|
||||
The platform columns are the point's stored solved pose — the same numbers the
|
||||
align move commands — so the sheet says what the machine would do, not a second
|
||||
solution computed for the sheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
|
||||
|
||||
#: Column order of the operation table. First block is the pixel, second the
|
||||
#: sensor-frame position, third the platform target, last the mapping identity —
|
||||
#: the same grouping the data-format document uses.
|
||||
OPERATION_TABLE_FIELDS: tuple[str, ...] = (
|
||||
"order",
|
||||
"site_id",
|
||||
"u_px",
|
||||
"v_px",
|
||||
"x_sensor_mm",
|
||||
"y_sensor_mm",
|
||||
"z_sensor_mm",
|
||||
"x_platform_mm",
|
||||
"y_platform_mm",
|
||||
"z_platform_mm",
|
||||
"yaw_deg",
|
||||
"pitch_deg",
|
||||
"origin_raster_x_px",
|
||||
"origin_raster_y_px",
|
||||
"raster_width_px",
|
||||
"raster_height_px",
|
||||
"u_positive",
|
||||
"v_positive",
|
||||
"mapping_sha256",
|
||||
)
|
||||
|
||||
#: One pixel of the 1024-wide production raster is about 0.0147 mm, so a gap
|
||||
#: larger than this means the recorded point and the map no longer agree.
|
||||
_POINT_MATCH_MM = 1e-9
|
||||
|
||||
|
||||
def operation_table_rows(rows, flat: FlatSurface) -> list[dict]:
|
||||
"""One dict per queue row, in queue order, ready for :data:`OPERATION_TABLE_FIELDS`.
|
||||
|
||||
``rows`` are the controller's ``SiteRow`` snapshots. A row without UV pixels,
|
||||
without a solved pose, or whose recorded sensor position no longer matches the
|
||||
current map refuses the export: an operation sheet that points at a pixel the
|
||||
record does not describe is worse than no sheet.
|
||||
"""
|
||||
if flat is None:
|
||||
raise ValueError(t("cap.queue.export_no_map"))
|
||||
origin_x, origin_y = uv_origin(flat)
|
||||
identity = mapping_sha256(flat)
|
||||
records: list[dict] = []
|
||||
for order, row in enumerate(rows, 1):
|
||||
if row.uv_px is None:
|
||||
raise ValueError(t("cap.queue.export_missing_uv").format(order=order))
|
||||
if row.pose is None:
|
||||
raise ValueError(t("cap.queue.export_no_pose").format(order=order))
|
||||
site = site_at(
|
||||
flat,
|
||||
int(row.uv_px[0]),
|
||||
int(row.uv_px[1]),
|
||||
origin=(origin_x, origin_y),
|
||||
mapping_digest=identity,
|
||||
)
|
||||
recorded = np.asarray(
|
||||
[float(row.position[axis]) for axis in "xyz"], dtype=np.float64
|
||||
)
|
||||
if not np.allclose(recorded, site.position_sensor_mm, rtol=0, atol=_POINT_MATCH_MM):
|
||||
raise ValueError(t("cap.queue.export_edited_point").format(order=order))
|
||||
pose = row.pose
|
||||
records.append(
|
||||
{
|
||||
"order": order,
|
||||
"site_id": row.site_id,
|
||||
"u_px": site.u_px,
|
||||
"v_px": site.v_px,
|
||||
**{f"{axis}_sensor_mm": float(row.position[axis]) for axis in "xyz"},
|
||||
**{
|
||||
f"{axis}_platform_mm": float(pose[axis])
|
||||
for axis in ("x", "y", "z")
|
||||
},
|
||||
"yaw_deg": float(pose["yaw"]),
|
||||
"pitch_deg": float(pose["pitch"]),
|
||||
"origin_raster_x_px": origin_x,
|
||||
"origin_raster_y_px": origin_y,
|
||||
"raster_width_px": flat.width,
|
||||
"raster_height_px": flat.height,
|
||||
"u_positive": "right",
|
||||
"v_positive": "up",
|
||||
"mapping_sha256": identity,
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def write_operation_table(path: str | Path, rows, flat: FlatSurface) -> Path:
|
||||
"""Write the operation table as UTF-8 CSV with the frozen field order."""
|
||||
if not rows:
|
||||
raise ValueError(t("cap.queue.export_no_points"))
|
||||
records = operation_table_rows(rows, flat)
|
||||
destination = Path(path)
|
||||
with destination.open("w", newline="", encoding="utf-8-sig") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=list(OPERATION_TABLE_FIELDS))
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
return destination
|
||||
@@ -11,8 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -74,6 +72,7 @@ from .bus_panel import default_bas_dir
|
||||
from .calibration_panel import CalibrationPanel
|
||||
from .device_setup import DeviceSetupDialog
|
||||
from .finger_source_panel import FingerSourcePanel
|
||||
from .reveal import reveal
|
||||
from .sensor_panel import FingerPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
from .status_strip import RunBar, StatusStrip
|
||||
@@ -143,6 +142,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self._last_snapshot = Snapshot()
|
||||
self._pending: dict[int, str] = {}
|
||||
self._last_error = ""
|
||||
#: Whether the move in flight was requested by the capture page, so that the
|
||||
#: page can be told the alignment finished or did not. A move the finger page
|
||||
#: asked for must never mark a capture point as aligned.
|
||||
self._capture_alignment = False
|
||||
|
||||
# Apply the remembered language before anything is built. The translator
|
||||
# is process-global, and only the menu ever set it, so a saved language
|
||||
@@ -308,7 +311,13 @@ class AutoCalMainWindow(QMainWindow):
|
||||
source_kind=str(self.prefs.get("finger_source_kind") or "video"),
|
||||
camera_index=self.prefs.get("finger_camera_index") or 0,
|
||||
)
|
||||
self.calibration_panel = CalibrationPanel(self.finger_source_panel, body)
|
||||
self.calibration_panel = CalibrationPanel(
|
||||
self.finger_source_panel,
|
||||
body,
|
||||
prefs=self.prefs,
|
||||
solver=self.finger_stack,
|
||||
bus_problem=self._capture_bus_problem,
|
||||
)
|
||||
self.calibration_panel.loadBundleRequested.connect(self._on_load_bundle)
|
||||
self.workspace = WorkspaceTabs(
|
||||
self.axis_panel,
|
||||
@@ -381,8 +390,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.finger_panel.executeRequested.connect(self._on_execute_pose)
|
||||
self.finger_panel.stopRequested.connect(self._on_stop_all)
|
||||
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
|
||||
self.calibration_panel.multipoint.bind_finger(self.finger_panel)
|
||||
self.calibration_panel.multipoint.alignRequested.connect(self._on_align_multipoint)
|
||||
self.calibration_panel.alignRequested.connect(self._on_align_multipoint)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
@@ -416,6 +424,20 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if problem:
|
||||
self.finger_panel.set_move_status(f"✗ {problem}")
|
||||
|
||||
def _capture_bus_problem(self) -> str:
|
||||
"""Why the capture page's bus check fails, or ``""`` when it passes.
|
||||
|
||||
The capture page may only start on a connected controller with an
|
||||
initialised bus and a sound axis mapping; the page renders this answer and
|
||||
does not read the service itself.
|
||||
"""
|
||||
service = self.poller.service
|
||||
if not service.connected:
|
||||
return "the controller is not connected"
|
||||
if not service.bus.initialized:
|
||||
return "the bus is not initialised"
|
||||
return service.roles_problem()
|
||||
|
||||
@property
|
||||
def _press_axis(self) -> PressAxis:
|
||||
"""How the press axis's coordinate relates to a contact height.
|
||||
@@ -443,7 +465,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
export, a missing file or an unreadable mesh must leave the rest of the
|
||||
app alone and report the reason.
|
||||
"""
|
||||
if self.calibration_panel.trial.session is not None:
|
||||
if self.calibration_panel.session_open:
|
||||
raise ValueError(t("calibration.close_before_mapping"))
|
||||
bundle = load_bundle(
|
||||
path,
|
||||
@@ -552,6 +574,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.prefs["finger_sensor_offset_mm"] = list(self.finger_panel.sensor_offset_mm)
|
||||
self.prefs["finger_sensor_yaw_deg"] = self.finger_panel.sensor_yaw_deg
|
||||
self.prefs["finger_tool_xy_mm"] = list(self.finger_panel.tool_xy_mm)
|
||||
# The queue's poses were solved with the machinery that just changed, so the
|
||||
# capture page re-solves them: writes back while idle, display-only when the
|
||||
# queue is frozen between trials.
|
||||
self.calibration_panel.resolve_queue()
|
||||
|
||||
# ---------------------------------------------------------------- run bar
|
||||
|
||||
@@ -833,6 +859,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
|
||||
self.mover.failed.connect(self._on_move_failed)
|
||||
self.mover.finished.connect(self._on_move_finished)
|
||||
self.mover.cancelled.connect(self._on_move_cancelled)
|
||||
|
||||
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||
@@ -1099,6 +1126,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self._pending[token] = ""
|
||||
|
||||
def _on_jog(self, axis: int, forward: bool) -> None:
|
||||
self._capture_platform_moved()
|
||||
token = self.poller.submit(lambda service: service.jog(axis, forward))
|
||||
self._pending[token] = ""
|
||||
|
||||
@@ -1153,11 +1181,16 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self._start_move(plan, t("move.execute"))
|
||||
|
||||
def _on_align_multipoint(self, targets: dict) -> None:
|
||||
"""Same five-axis move as the finger screen, for the point chosen on 选择测点.
|
||||
"""Same five-axis move as the finger screen, for the current capture point.
|
||||
|
||||
The calibration panel hands over geometry, not controller coordinates, so
|
||||
The calibration page hands over geometry, not controller coordinates, so
|
||||
the press-axis relation is applied here as well — one place decides which
|
||||
roles a solved point may command, whichever screen asked for the move.
|
||||
|
||||
The page is told the outcome: only a move that finished may mark the point
|
||||
aligned, so a refused, cancelled or failed move leaves "start capture"
|
||||
unclickable. That report is why ``_capture_alignment`` exists: a move the
|
||||
finger screen asked for must never mark a capture point aligned.
|
||||
"""
|
||||
targets, held = press_targets(targets, self._press_axis)
|
||||
plan = plan_pose_move(
|
||||
@@ -1167,12 +1200,27 @@ class AutoCalMainWindow(QMainWindow):
|
||||
hold=held,
|
||||
together=True,
|
||||
)
|
||||
self._capture_alignment = True
|
||||
self._start_move(
|
||||
plan,
|
||||
t("multipoint.align"),
|
||||
report=self.calibration_panel.multipoint.message.setText,
|
||||
report=self.calibration_panel.set_message,
|
||||
)
|
||||
|
||||
def _capture_platform_moved(self) -> None:
|
||||
"""A move the capture page did not ask for: its "aligned" no longer holds."""
|
||||
self.calibration_panel.alignment_invalidated()
|
||||
|
||||
def _capture_move_outcome(self, ok: bool, reason: str = "") -> None:
|
||||
"""Tell the capture page how the move it asked for ended, once."""
|
||||
if not self._capture_alignment:
|
||||
return
|
||||
self._capture_alignment = False
|
||||
if ok:
|
||||
self.calibration_panel.alignment_succeeded()
|
||||
else:
|
||||
self.calibration_panel.alignment_failed(reason)
|
||||
|
||||
def _on_return_to_initial(self) -> None:
|
||||
"""Yaw, pitch and Z back to zero. Table X/Y park at the stored initial."""
|
||||
|
||||
@@ -1201,6 +1249,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if refusal is not None:
|
||||
say(refusal.message)
|
||||
self._refuse_move(refusal.message)
|
||||
self._capture_move_outcome(False, refusal.message)
|
||||
return
|
||||
# Confirm before the platform moves, with the numbers rather than a summary of
|
||||
# them: this is the operator's chance to notice that the simulation and the
|
||||
@@ -1236,12 +1285,16 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
say(t("move.cancelled"))
|
||||
self.finger_panel.set_move_status(t("move.cancelled"))
|
||||
self._capture_move_outcome(False, t("move.cancelled"))
|
||||
return
|
||||
if not self.mover.start(plan).ok:
|
||||
reason = self.mover.state.failure or t("move.busy")
|
||||
say(reason)
|
||||
self._refuse_move(reason)
|
||||
self._capture_move_outcome(False, reason)
|
||||
return
|
||||
if not self._capture_alignment:
|
||||
self._capture_platform_moved()
|
||||
self.finger_panel.set_move_status(plan.describe())
|
||||
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
|
||||
|
||||
@@ -1258,6 +1311,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if plan is not None:
|
||||
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
|
||||
self.status_strip.set_action_result(t("move.done"), True)
|
||||
self._capture_move_outcome(True)
|
||||
|
||||
def _on_move_failed(self, message: str) -> None:
|
||||
# A mover that gave up leaves an axis wherever it stopped: stop the rest
|
||||
@@ -1265,15 +1319,25 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.finger_panel.set_move_status(f"✗ {message}")
|
||||
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
|
||||
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
|
||||
self._capture_move_outcome(False, message)
|
||||
self.poller.submit(lambda service: service.stop_all())
|
||||
|
||||
def _on_move_cancelled(self) -> None:
|
||||
"""The operator stopped a move: the axes stay where they stopped."""
|
||||
self.finger_panel.set_move_status(t("move.cancelled"))
|
||||
self.status_strip.set_action_result(t("move.cancelled"), False)
|
||||
self._capture_move_outcome(False, t("move.cancelled"))
|
||||
|
||||
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||
self._capture_platform_moved()
|
||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||
|
||||
def _on_move_rel(self, axis: int, distance: float) -> None:
|
||||
self._capture_platform_moved()
|
||||
self.poller.submit(lambda service: service.move_rel(axis, distance))
|
||||
|
||||
def _on_home(self, axis: int) -> None:
|
||||
self._capture_platform_moved()
|
||||
self.poller.submit(lambda service: service.home(axis))
|
||||
|
||||
def _on_clear_alarm(self, axis: int) -> None:
|
||||
@@ -1353,20 +1417,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
def _on_reveal_config(self) -> None:
|
||||
from orisys_app_shared.paths import config_dir
|
||||
|
||||
self._reveal(config_dir())
|
||||
reveal(config_dir())
|
||||
|
||||
def _on_reveal_outputs(self) -> None:
|
||||
self._reveal(outputs_dir())
|
||||
|
||||
@staticmethod
|
||||
def _reveal(path: Path) -> None:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(["explorer", str(path)])
|
||||
else: # pragma: no cover - the platform is Windows
|
||||
subprocess.Popen(["xdg-open", str(path)])
|
||||
except OSError:
|
||||
logger.exception("Could not open %s", path)
|
||||
reveal(outputs_dir())
|
||||
|
||||
def _on_about(self) -> None:
|
||||
QMessageBox.information(
|
||||
|
||||
Reference in New Issue
Block a user