重构 Autocal 采集页:单一队列与控制器驱动的主按钮

选点、对准、加载与录像分散在两个面板,状态靠隐藏相位判断。现改由不依赖 Qt 的采集控制器驱动,并加入录像选项、汇总平铺副本、保持到时自动结束、会话结束回到空闲,以及无设备时的模拟采集。已运行 Autocal 非 Qt 测试并通过范围内门禁;界面测试待 Windows 复核,未做实机验收。
This commit is contained in:
ykcheungab
2026-10-18 16:04:32 +08:00
parent 85d0b396e3
commit 06e06126ca
44 changed files with 7769 additions and 2218 deletions
@@ -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 {})
+134 -4
View File
@@ -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"),
}
+9 -1
View File
@@ -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
+35 -4
View File
@@ -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
+75 -21
View File
@@ -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(