Present replay 3D with the cursor and open Analyze on a remembered video

Replay left the 3D pane on the rest mesh or a sparse overlay, and Analyze skipped the setup dialog unless the file was re-picked. Live Run no longer starts a chain on a video path. Verified with Studio tests for flush, overlay mask, Analyze open, live-video refusal, and locales.
This commit is contained in:
ykcheungab
2026-10-05 16:52:27 +08:00
parent 3433eb0fec
commit c0a10e6adb
26 changed files with 2226 additions and 158 deletions
@@ -0,0 +1,607 @@
"""Manufacture the workspace the analysis pass already expects, for a bare video.
A video with no recording beside it has no ``frames.csv`` and no ``session.json``. The
pass reads nothing else: frame identity, signal time, the segment index and the chain
specification all come from those two files. There are two ways to make a bare video
analysable — teach :class:`~orisys_app_shared.analysis.service.AnalysisService` a second
input shape, or **generate the metadata it already reads**. This module does the second,
because the pass's rules about frame identity (Part A §5–§6 of ``replay-analysis.md``)
are exactly the rules a bare video needs and a second code path would be a second set of
them.
What is generated, and what it therefore means:
* **``frames.csv``** — one row per *container* frame of the video, with
``t_rel_s = index / fps``. A bare video has only container time, so here the container's
own clock **is** the signal time base; for a recording the two are different numbers
(33 frames at 30 fps is 1.100 s of video carrying 1.920 s of signal) and this module
never touches that path. Frame ids start at 1 and are dense, and ``in_video`` is 1 for
every row: nothing was dropped, because nothing was captured.
* **``session.json``** — the identity the pass keys a result by, the ``device`` block that
decides which chain to drive, and the ``stats`` the segment plan is read from. It is
**deterministic in the video and the chosen profile**: no wall-clock time, no
generated-at field inside anything ``recording_identity`` reads, so reopening the same
video resolves to the same key and reuses the stored result rather than recomputing it.
* **``raw_0001.mp4``** — the operator's own file, hard-linked when the filesystem allows
it and copied when it does not. One file is one segment, which is why a *multi-file*
input is refused rather than reduced to its first file (see :func:`resolve_video`).
**The workspace is app-owned.** It is written under
:func:`~orisys_app_shared.paths.video_workspace_dir` — the same per-user derived-data root
the analysis cache lives in — and never beside the video: the folder the operator keeps
their footage in is their data, and the app has no business writing a frame index into it.
The session document carries a ``synthetic`` block naming the source video, and the pass
copies it into the result's provenance, so a recomputed measurement can never be read as a
recorded one.
"""
from __future__ import annotations
import csv
import hashlib
import json
import logging
import os
import shutil
import time
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from orisys_app_shared import paths as paths_module
from orisys_app_shared.record.playback import RecordingFrame
if TYPE_CHECKING: # pragma: no cover - typing only
from orisys_app_shared.sensors.bundle_catalog import CatalogEntry
logger = logging.getLogger(__name__)
__all__ = [
"BareVideoError",
"BareVideoSetup",
"SyntheticWorkspace",
"VideoFacts",
"build_workspace",
"is_synthetic",
"profiles_for_video",
"read_synthetic",
"resolve_video",
"session_document",
"setup_blocked_reason",
"source_video",
"synthetic_frame_index",
"workspace_directory",
]
#: Generator identity, recorded in the session's ``synthetic`` block. A change to how the
#: frame index is derived must change this string: two workspaces with different rows must
#: not look like the same input.
GENERATOR = "orisys-bare-video-1"
#: The two files the pass reads, plus the single segment they name.
FRAMES_FILENAME = "frames.csv"
SESSION_FILENAME = "session.json"
VIDEO_FILENAME = "raw_0001.mp4"
#: Column order of ``frames.csv``; ``RecordingReader`` reads these names, and the ignored
#: ones are written empty rather than omitted so the file says what it does not know.
FRAME_COLUMNS = (
"frame_id",
"t_rel_s",
"acquired_at",
"segment",
"segment_frame",
"in_video",
"dropped",
)
#: Suffixes a video is recognised by. Only used to *find* the one file of an input; a file
#: with an unusual extension is still accepted when the operator points straight at it.
VIDEO_SUFFIXES = (".mp4", ".avi", ".mov", ".mkv", ".m4v", ".wmv", ".mpg", ".mpeg")
#: The hash window used for the video's identity. Two megabytes of a multi-gigabyte file is
#: enough to tell one video from another and cheap enough to run when the file is opened;
#: the file's size and name travel with it, so a truncated copy is a different video too.
_IDENTITY_CHUNK = 1 << 20
class BareVideoError(RuntimeError):
"""The input cannot become an analysable workspace, with the reason it cannot."""
@dataclass(frozen=True)
class VideoFacts:
"""What the container says about the video, measured rather than assumed."""
path: Path
frame_count: int
fps: float
width: int
height: int
@property
def duration_s(self) -> float:
return float(self.frame_count) / float(self.fps) if self.fps > 0 else 0.0
@dataclass(frozen=True)
class BareVideoSetup:
"""What the operator chose in the setup prompt.
``calibration`` is ``None`` for raw force: "no calibration" is a choice, not a missing
value, and the difference decides both the chain's ``cal_path`` and the result's
calibration key component.
"""
profile_ref: str
calibration: Path | None = None
#: The frame the tracker reference is reset at, or ``None`` for the pass's own default
#: (the first frame it processes — which *is* a clean tracker, so the first frame is the
#: reference without a reset).
reference_frame: int | None = None
@dataclass(frozen=True)
class SyntheticWorkspace:
"""One generated workspace, and the facts the caller needs about it."""
directory: Path
video: Path
facts: VideoFacts
frames: tuple[RecordingFrame, ...]
session: dict[str, Any]
@property
def frame_ids(self) -> tuple[int, ...]:
return tuple(int(row.frame_id) for row in self.frames)
@property
def first_frame_id(self) -> int:
return int(self.frames[0].frame_id) if self.frames else 0
@property
def last_frame_id(self) -> int:
return int(self.frames[-1].frame_id) if self.frames else 0
# ------------------------------------------------------------------------ the input
def _segment_family(video: Path) -> list[Path]:
"""Every sibling that is another *segment* of the same recording.
A recording the recorder split is ``raw_0001.mp4``, ``raw_0002.mp4``, …; a camera that
writes numbered files produces ``clip_0001.mp4`` beside ``clip_0002.mp4``. Both are one
measurement, so opening one member and analysing it silently would answer with a part
of the footage and present it as the whole — a measurement error, not a missing feature.
"""
stem = video.stem
digits = ""
for char in reversed(stem):
if not char.isdigit():
break
digits = char + digits
if len(digits) < 3:
return [video]
base = stem[: len(stem) - len(digits)]
siblings = sorted(
path
for path in video.parent.iterdir()
if path.is_file()
and path.suffix.lower() == video.suffix.lower()
and path.stem.startswith(base)
and path.stem[len(base) :].isdigit()
and len(path.stem) - len(base) >= 3
)
return siblings or [video]
def resolve_video(path: str | Path) -> Path:
"""The one video file an input names, or a refusal saying why it is not one.
A **folder** is read as "the videos in here": exactly one is the video, and more than
one is refused by name — analysing the first of several files while calling the result
the folder's measurement is the silent half-answer this exists to prevent. A **file**
that belongs to a numbered set (``raw_0001.mp4`` beside ``raw_0002.mp4``) is refused
for the same reason.
"""
candidate = Path(path)
if candidate.is_dir():
found = [
child
for child in sorted(candidate.iterdir())
if child.is_file() and child.suffix.lower() in VIDEO_SUFFIXES
]
if not found:
raise BareVideoError(f"{candidate} holds no video file")
if len(found) > 1:
names = ", ".join(child.name for child in found[:6])
raise BareVideoError(
f"{candidate} holds {len(found)} video files ({names}): one pass reads one "
"file, and a split recording is more than one measurement. Open the single "
"file to analyse."
)
candidate = found[0]
elif not candidate.is_file():
raise BareVideoError(f"{candidate} is neither a video file nor a folder")
if candidate.suffix.lower() not in VIDEO_SUFFIXES:
raise BareVideoError(
f"{candidate.name} is not a video this pass recognises "
f"({'/'.join(VIDEO_SUFFIXES)})"
)
family = _segment_family(candidate)
if len(family) > 1:
names = ", ".join(item.name for item in family[:6])
raise BareVideoError(
f"{candidate.name} is one segment of a split recording ({names}): a pass over "
"one segment would describe part of the measurement as the whole of it. "
"Analyse a single, complete video file."
)
return candidate
def probe(video: str | Path) -> VideoFacts:
"""Frames, rate and size, from the container. Raises when it cannot be decoded."""
import cv2
path = Path(video)
capture = cv2.VideoCapture(str(path))
try:
if not capture.isOpened():
raise BareVideoError(f"{path.name} could not be opened as a video")
fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0)
count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
finally:
capture.release()
if not (fps > 0.0) or count <= 0:
# A timeline nobody can place a frame on: either number being absent makes every
# ``t_rel_s`` a guess, and a guess here is a cursor that points at another frame.
raise BareVideoError(
f"{path.name} reports no frame count or frame rate ({count} frames, {fps} fps), "
"so it has no timeline to analyse"
)
return VideoFacts(path=path, frame_count=count, fps=fps, width=width, height=height)
def synthetic_frame_index(facts: VideoFacts) -> tuple[RecordingFrame, ...]:
"""One row per container frame: frame id, signal time, segment and flags.
``t_rel_s`` is the container's own clock divided out, in seconds, from a dense frame
index — the only time base a bare video has. Nothing is marked dropped or absent,
because nothing was captured: the video *is* the record of the frames it holds.
"""
rows: list[RecordingFrame] = []
for index in range(int(facts.frame_count)):
rows.append(
RecordingFrame(
frame_id=index + 1,
t_rel_s=round(index / float(facts.fps), 9),
acquired_at="",
segment=1,
segment_frame=index + 1,
in_video=True,
dropped=False,
)
)
return tuple(rows)
def video_identity(video: str | Path) -> tuple[str, dict[str, Any]]:
"""A path-independent identity for a video, with the facts it was computed from.
Deliberately not the path: the same file opened from two places is the same video, and
a re-used path with different footage is not. Size and a two-megabyte head/tail digest
are cheap enough to take on every open and strong enough that the workspace directory
name changes when the footage does — which is what makes the analysis *key* stable for
an unchanged video and different for a changed one.
"""
path = Path(video)
size = int(path.stat().st_size)
hasher = hashlib.blake2b(digest_size=8)
hasher.update(path.name.encode("utf-8", "replace"))
hasher.update(str(size).encode("ascii"))
with path.open("rb") as handle:
head = handle.read(_IDENTITY_CHUNK)
hasher.update(head)
if size > _IDENTITY_CHUNK:
handle.seek(max(0, size - _IDENTITY_CHUNK))
hasher.update(handle.read(_IDENTITY_CHUNK))
digest = hasher.hexdigest()
return digest, {"name": path.name, "bytes": size, "digest": digest}
def _session_id(identity: str) -> int:
"""A stable integer session id from the video's own identity.
The recorder's counter is meaningless for a video nobody recorded, and a wall-clock
one would give the same video a new analysis key every time it was opened. The top
half of the identity digest is both stable and collision-free in practice.
"""
return int(identity[:12], 16) % (2**31)
def _calibration_identity(path: Path | None) -> str:
"""A calibration's key component: its name plus a digest of its own content.
The *content* is what decides the numbers the chain computes, so a coefficient file
edited in place must not keep serving a result computed from the old one. The name
travels with it because a person reading the provenance needs to know which file.
"""
if path is None:
return ""
try:
digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()[:12]
except OSError:
return Path(path).name
return f"{Path(path).name}@{digest}"
def session_document(
facts: VideoFacts,
frames: Sequence[RecordingFrame],
*,
profile: CatalogEntry,
calibration: Path | None,
reference_frame: int | None,
identity: str,
source_facts: dict[str, Any],
) -> dict[str, Any]:
"""The ``session.json`` a bare video is analysed under.
Shaped exactly like a recording's, because the pass reads it through the same
``RecordingReader`` and ``chain_spec_for``: ``device`` decides which chain is driven and
with which configuration, ``stats`` decides how many segments there should be, and
``files`` names them. Everything ``recording_identity`` hashes is derived from the video
and the choice, never from the clock — that is what makes reopening the video a cache
hit rather than a second minute of work.
"""
sensor_json = profile.sensor_json_path
config_ref = str(sensor_json) if sensor_json is not None else str(profile.bundle_path)
return {
"session_id": _session_id(identity),
"started_at": "",
"ended_at": "",
"device": {
"sensor_type": _chain_type(profile),
"config_ref": config_ref,
"config_sha256": _file_sha256(sensor_json),
},
"stats": {
"frames": len(frames),
"video_frames": len(frames),
"segments": 1,
"fps": float(facts.fps),
},
"files": {"video": [VIDEO_FILENAME]},
"preroll_frames": 0,
"preroll_segment": "",
"calibration_id": _calibration_identity(calibration),
"depth": {"stored": True, "replay": "analysis"},
"synthetic": {
"generator": GENERATOR,
"source_video": str(Path(facts.path).resolve()),
"source_name": source_facts.get("name", Path(facts.path).name),
"source_bytes": int(source_facts.get("bytes") or 0),
"source_digest": str(source_facts.get("digest") or identity),
"identity": identity,
"profile_ref": str(profile.ref),
"profile_id": str(profile.profile_id or ""),
"calibration": "" if calibration is None else str(Path(calibration).resolve()),
"reference_frame": None
if reference_frame is None
else int(reference_frame),
"frame_index": (
"generated: one row per container frame, t_rel_s = index / fps; the "
"container's clock is the only time base a bare video has"
),
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
},
}
def _chain_type(profile: CatalogEntry) -> str:
"""Which chain this profile is driven by (``finger`` or ``box``).
The SDK's product family is the answer, and the two names the app's chains know are
``finger`` and ``box``: Mini is a single-camera box pipeline, so it drives the box
chain. A family that maps to neither is refused here rather than discovered at the
first chain call.
"""
family = str(profile.product_family or "").strip().lower()
if family == "finger":
return "finger"
if family in ("box", "mini"):
return "box"
raise BareVideoError(
f"{profile.name} declares product family {profile.product_family or 'unknown'!r}, "
"which this pass has no chain for"
)
def _file_sha256(path: Path | None) -> str:
if path is None:
return ""
try:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
except OSError:
return ""
# -------------------------------------------------------------------- the workspace
def workspace_directory(identity: str, *, root: str | Path | None = None) -> Path:
"""Where one video's workspace lives: the app's own root, keyed by the video."""
base = paths_module.video_workspace_dir() if root is None else Path(root)
return Path(base) / identity
def _write_frames(path: Path, frames: Sequence[RecordingFrame]) -> None:
temporary = path.with_name(path.name + ".part")
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
writer = csv.writer(handle, lineterminator="\n")
writer.writerow(FRAME_COLUMNS)
for row in frames:
writer.writerow(
[
int(row.frame_id),
f"{float(row.t_rel_s):.9f}".rstrip("0").rstrip(".")
if row.t_rel_s
else "0",
row.acquired_at,
int(row.segment),
int(row.segment_frame),
1 if row.in_video else 0,
1 if row.dropped else 0,
]
)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def _write_session(path: Path, document: dict[str, Any]) -> None:
temporary = path.with_name(path.name + ".part")
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(document, handle, indent=2, ensure_ascii=False, sort_keys=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def _link_or_copy(video: Path, target: Path) -> str:
"""The video inside the workspace: a hard link when possible, else a copy.
A link is how the workspace can hold *the* video without holding a second copy of it —
a ten-minute clip is gigabytes. Where the filesystem refuses one (the workspace and the
video on different volumes, a filesystem without links), the copy is the honest
fallback: the numbers do not depend on which happened, and the session records it.
"""
if target.exists():
try:
if os.path.samefile(target, video):
return "existing"
except OSError:
pass
target.unlink()
try:
os.link(video, target)
return "link"
except OSError:
logger.info("could not link %s into the workspace; copying instead", video)
shutil.copy2(video, target)
return "copy"
def build_workspace(
video: str | Path,
*,
profile: CatalogEntry,
setup: BareVideoSetup | None = None,
root: str | Path | None = None,
) -> SyntheticWorkspace:
"""Generate (or refresh) the workspace for one video, and return what it says.
Idempotent by construction: the same video and profile produce the same rows, the same
document and the same directory, so reopening a video is a rewrite of two small text
files and a reuse of the stored analysis result rather than a second pass.
"""
resolved = resolve_video(video)
facts = probe(resolved)
identity, source_facts = video_identity(resolved)
calibration = None if setup is None else setup.calibration
reference_frame = None if setup is None else setup.reference_frame
if calibration is not None and not Path(calibration).is_file():
raise BareVideoError(f"the chosen calibration {calibration} is not there")
frames = synthetic_frame_index(facts)
document = session_document(
facts,
frames,
profile=profile,
calibration=calibration,
reference_frame=reference_frame,
identity=identity,
source_facts=source_facts,
)
directory = workspace_directory(identity, root=root)
directory.mkdir(parents=True, exist_ok=True)
mode = _link_or_copy(resolved, directory / VIDEO_FILENAME)
document["synthetic"]["video_in_workspace"] = mode
_write_frames(directory / FRAMES_FILENAME, frames)
_write_session(directory / SESSION_FILENAME, document)
return SyntheticWorkspace(
directory=directory,
video=resolved,
facts=facts,
frames=frames,
session=document,
)
def read_synthetic(directory: str | Path) -> dict[str, Any] | None:
"""The ``synthetic`` block of a workspace, or ``None`` for a real recording.
The one question the app asks a directory to know what it is about to analyse: a
recording carries no such block, so its absence is the answer "this is somebody's
measured session", which must never be given the bare-video treatment.
"""
path = Path(directory) / SESSION_FILENAME
if not path.is_file():
return None
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(document, dict):
return None
block = document.get("synthetic")
return dict(block) if isinstance(block, dict) else None
def is_synthetic(directory: str | Path) -> bool:
"""Whether this directory is a generated workspace rather than a recording."""
return read_synthetic(directory) is not None
def source_video(directory: str | Path) -> Path | None:
"""The operator's video behind a workspace, when the block names one that exists."""
block = read_synthetic(directory)
if not block:
return None
named = str(block.get("source_video") or "")
if not named:
return None
path = Path(named)
return path if path.is_file() else None
def profiles_for_video(entries: Iterable[CatalogEntry]) -> list[CatalogEntry]:
"""Every catalog entry a bare video could be set up with, in catalog order.
**Including the ones that must be refused**, because their reason is what the operator
has to read: a profile the SDK rejected and a profile whose product family this app has
no chain for are both listed, each disabled with its own sentence
(:func:`setup_blocked_reason`), rather than missing from the list with nothing to say
why. What is left out is a folder no bundle could be read from at all — there is no
profile behind it, so there is nothing to process with and nothing to refuse *by*.
"""
return [entry for entry in entries if entry.profile is not None]
def setup_blocked_reason(entry: CatalogEntry) -> str | None:
"""Why this profile cannot be set up for a bare video, or ``None`` when it can.
Two reasons from two owners, and the order matters: the SDK's own verdict first
(``CatalogEntry.start_blocked_reason`` — a validation failure, or a ``profile_id`` two
bundles both declare), then this app's own limit (no processing chain for the profile's
product family). The SDK's answer wins when both apply, because it is about the bundle
the operator chose and there is something they can do about it.
"""
blocked = entry.start_blocked_reason
if blocked:
return blocked
try:
_chain_type(entry)
except BareVideoError as exc:
return str(exc)
return None
@@ -95,6 +95,10 @@ class SceneComposer:
#: The resolved ``#RRGGBB,...`` ramp both views build their lookup table from.
#: Set by the view when the theme changes; empty means this build's default.
self.ramp = str(ramp)
#: Optional flow-arrow policy for replay analysis. When set, overlays sample with
#: this policy instead of ``policy_for_settings`` so the 3D arrows match the 2D
#: canvas's ``paint_policy``. Live acquisition leaves it ``None``.
self.overlay_policy = None
def compose(
self,
@@ -253,7 +257,13 @@ class SceneComposer:
)
return flow_arrows, contact_arrow
policy = policy_for_settings(settings)
# Replay analysis can pin the same policy the 2D canvas uses (stored grid +
# display or painter threshold). Live leaves ``overlay_policy`` unset.
policy = (
self.overlay_policy
if self.overlay_policy is not None
else policy_for_settings(settings)
)
if (
settings.flow_arrows_enabled
and frame.flow_uv is not None