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
+505
View File
@@ -0,0 +1,505 @@
"""A bare video's generated workspace: the two files the pass reads, and what they say.
The pass has one input shape — a directory with ``frames.csv``, ``session.json`` and
``raw_*.mp4`` — and it is a good shape: frame identity comes from the recording's own
counter, signal time from its own table, and the chain from its own metadata. A bare video
gets that shape *manufactured* for it rather than a second code path in the pass, so these
tests defend three properties of the manufacture: the rows are the container's real frames,
the documents are deterministic enough that reopening the video is a cache hit, and nothing
is written anywhere near the operator's file.
No SDK and no real footage: a video written by OpenCV here is the input, and the pass itself
is exercised in ``apps/studio/tests/test_bare_video_analysis.py`` over recorded footage.
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
import pytest
from orisys_app_shared import paths as paths_module
from orisys_app_shared.analysis import bare_video
from orisys_app_shared.analysis.model import ForceSample, encode_frame, decode_frame
from orisys_app_shared.analysis.service import ChainSpec
from orisys_app_shared.record.playback import RecordingReader
FRAMES = 12
FPS = 30.0
# ------------------------------------------------------------------------ fixtures
def write_video(path: Path, *, frames: int = FRAMES, fps: float = FPS) -> Path:
"""A decodable video of ``frames`` distinct frames, written by OpenCV."""
cv2 = pytest.importorskip("cv2", reason="OpenCV writes and reads the fixture video")
import numpy as np
path.parent.mkdir(parents=True, exist_ok=True)
writer = cv2.VideoWriter(
str(path), cv2.VideoWriter_fourcc(*"mp4v"), float(fps), (64, 48)
)
assert writer.isOpened(), "the fixture video could not be opened for writing"
for index in range(frames):
image = np.full((48, 64, 3), 30 + index * 8, dtype=np.uint8)
image[::8, :, :] = 200
image[:, ::8, :] = 90
writer.write(image)
writer.release()
assert path.is_file() and path.stat().st_size > 0
return path
class _Entry:
"""The catalog-entry fields the workspace generator asks about."""
def __init__(self, *, family: str = "box", name: str = "Box", blocked: str = "",
sensor_json: Path | None = None, ref: str = "shipped/box",
has_profile: bool = True) -> None:
self.product_family = family
self.name = name
self.ref = ref
self.folder = ref.split("/")[-1]
self.profile_id = f"orisys.{family}.r01"
self.display_name = name
self.bundle_path = Path(sensor_json).parent if sensor_json else Path(".")
self.sensor_json_path = sensor_json
#: ``None`` stands for a folder no bundle could be read from at all.
self.profile = object() if has_profile else None
self._blocked = blocked
@property
def start_blocked_reason(self) -> str | None:
return self._blocked or None
@pytest.fixture
def video(tmp_path) -> Path:
return write_video(tmp_path / "operator" / "clip.mp4")
@pytest.fixture
def workspace_root(tmp_path, monkeypatch) -> Path:
"""The app-owned root the workspace goes to, pointed at this test."""
root = tmp_path / "derived" / "video-workspaces"
monkeypatch.setattr(paths_module, "video_workspace_dir", lambda: root)
return root
def _build(video: Path, **kwargs) -> bare_video.SyntheticWorkspace:
return bare_video.build_workspace(video, profile=_Entry(**kwargs), setup=None)
def _entry_set(directory: Path) -> set:
"""Every entry under ``directory`` as ``(relative path, size)``."""
found = set()
for path in Path(directory).rglob("*"):
relative = str(path.relative_to(directory))
found.add((relative, -1 if path.is_dir() else path.stat().st_size))
return found
# ------------------------------------------------------------------- the frame index
def test_the_frame_index_is_one_row_per_container_frame(video, workspace_root):
"""``frames.csv`` is the video's own frames: ids from 1, signal time from the rate.
A bare video has one clock — the container's — so ``t_rel_s`` is that clock divided
out, and every row is in the video because nothing was captured and nothing was lost.
"""
workspace = _build(video)
assert [row.frame_id for row in workspace.frames] == list(range(1, FRAMES + 1))
assert [row.segment for row in workspace.frames] == [1] * FRAMES
assert [row.segment_frame for row in workspace.frames] == list(range(1, FRAMES + 1))
assert all(row.in_video for row in workspace.frames)
assert not any(row.dropped for row in workspace.frames)
assert workspace.frames[0].t_rel_s == 0.0
assert workspace.frames[-1].t_rel_s == pytest.approx((FRAMES - 1) / FPS, abs=1e-9)
# The file is read by the pass's own reader, so it has to say the same thing the
# in-memory rows do — that equality is the whole point of generating it.
reader = RecordingReader(workspace.directory)
try:
loaded = reader.info().frames
finally:
reader.close()
assert [(row.frame_id, row.segment, row.segment_frame, row.in_video) for row in loaded] == [
(row.frame_id, row.segment, row.segment_frame, row.in_video) for row in workspace.frames
]
assert [row.t_rel_s for row in loaded] == pytest.approx(
[row.t_rel_s for row in workspace.frames], abs=1e-9
)
def test_the_session_document_says_what_the_pass_needs(video, workspace_root, tmp_path):
"""``device``/``stats``/``files`` are what the chain and the segment plan are read from."""
config = tmp_path / "sensors" / "box" / "sensor.json"
config.parent.mkdir(parents=True)
config.write_text('{"camera": {"fps": 30}}', encoding="utf-8")
workspace = _build(video, sensor_json=config)
session = workspace.session
assert session["device"]["sensor_type"] == "box"
assert session["device"]["config_ref"] == str(config)
assert session["device"]["config_sha256"]
assert session["stats"]["frames"] == FRAMES
assert session["stats"]["video_frames"] == FRAMES
assert session["stats"]["segments"] == 1
assert session["files"]["video"] == ["raw_0001.mp4"]
assert (workspace.directory / "raw_0001.mp4").is_file()
# ...and the pass's own chain reader agrees, from the file rather than from memory.
reader = RecordingReader(workspace.directory)
try:
assert reader.session()["device"]["sensor_type"] == "box"
finally:
reader.close()
def test_the_workspace_is_marked_synthetic(video, workspace_root):
"""A generated workspace must be distinguishable from a recording, forever.
``read_synthetic`` is the question the app asks a directory before it decides how to
treat it, and it answers ``None`` for a real recording — the difference between
"somebody measured this" and "this was generated here".
"""
workspace = _build(video)
block = bare_video.read_synthetic(workspace.directory)
assert block is not None
assert block["generator"] == bare_video.GENERATOR
assert block["source_video"] == str(Path(video).resolve())
assert block["frame_index"].startswith("generated:")
assert bare_video.is_synthetic(workspace.directory)
assert bare_video.source_video(workspace.directory) == Path(video).resolve()
# A real recording's session.json has no such block, and a folder without one is not
# a workspace at all.
plain = workspace.directory.parent / "plain"
plain.mkdir()
(plain / "session.json").write_text(json.dumps({"session_id": 1}), encoding="utf-8")
assert bare_video.read_synthetic(plain) is None
assert bare_video.read_synthetic(workspace.directory / "nothing-here") is None
def test_reopening_the_same_video_is_the_same_analysis_key(video, workspace_root, tmp_path):
"""Determinism is not tidiness: the key is what makes a second open a cache read.
``recording_identity`` hashes the session's own facts, so anything derived from the
clock would give the same video a new result directory on every open — and the pass
would run again every time.
"""
config = tmp_path / "sensors" / "box" / "sensor.json"
config.parent.mkdir(parents=True)
config.write_text("{}", encoding="utf-8")
chain = ChainSpec(sensor_type="box", config_ref=config)
first = _build(video, sensor_json=config)
again = _build(video, sensor_json=config)
assert again.directory == first.directory
assert again.session["session_id"] == first.session["session_id"]
from orisys_app_shared.analysis.service import AnalysisService
keys = [
AnalysisService(item.directory, chain=chain).dependency_report()["key"].digest
for item in (first, again)
]
assert keys[0] == keys[1], "reopening the video produced a different result key"
# A different video is a different workspace and a different key.
other = write_video(video.parent / "other.mp4", frames=FRAMES + 3)
third = _build(other, sensor_json=config)
assert third.directory != first.directory
assert (
AnalysisService(third.directory, chain=chain).dependency_report()["key"].digest
!= keys[0]
)
def test_the_calibration_is_part_of_the_key_by_its_content(video, workspace_root, tmp_path):
"""Coefficients edited in place must not keep serving a result computed from the old ones."""
config = tmp_path / "sensors" / "box" / "sensor.json"
config.parent.mkdir(parents=True)
config.write_text("{}", encoding="utf-8")
calibration = tmp_path / "force_calibration.json"
calibration.write_text('{"gain": 1}', encoding="utf-8")
first = bare_video.build_workspace(
video,
profile=_Entry(sensor_json=config),
setup=bare_video.BareVideoSetup(profile_ref="shipped/box", calibration=calibration),
)
calibration.write_text('{"gain": 2}', encoding="utf-8")
second = bare_video.build_workspace(
video,
profile=_Entry(sensor_json=config),
setup=bare_video.BareVideoSetup(profile_ref="shipped/box", calibration=calibration),
)
assert first.session["calibration_id"] != second.session["calibration_id"]
# The name still travels with the digest, so a reader knows which file it was.
assert second.session["calibration_id"].startswith("force_calibration.json@")
raw = bare_video.build_workspace(
video,
profile=_Entry(sensor_json=config),
setup=bare_video.BareVideoSetup(profile_ref="shipped/box", calibration=None),
)
assert raw.session["calibration_id"] == ""
def test_a_chosen_calibration_that_is_not_there_is_refused(video, workspace_root, tmp_path):
with pytest.raises(bare_video.BareVideoError):
bare_video.build_workspace(
video,
profile=_Entry(),
setup=bare_video.BareVideoSetup(
profile_ref="shipped/box", calibration=tmp_path / "absent.json"
),
)
# ------------------------------------------------------------------- what is refused
def test_a_split_recording_is_refused_rather_than_half_analysed(tmp_path):
"""More than one file is one measurement, not one input: refuse it, by name.
Analysing the first segment of a split recording and calling the result that
recording's measurement is a measurement error, and it is silent — the only visible
symptom is a short timeline.
"""
segments = tmp_path / "split"
segments.mkdir()
write_video(segments / "raw_0001.mp4")
write_video(segments / "raw_0002.mp4", frames=4)
with pytest.raises(bare_video.BareVideoError) as failure:
bare_video.resolve_video(segments / "raw_0001.mp4")
assert "raw_0001.mp4" in str(failure.value) and "raw_0002.mp4" in str(failure.value)
assert "split recording" in str(failure.value)
# A folder is read as "the video in here", and a folder of two is the same refusal.
with pytest.raises(bare_video.BareVideoError) as folder_failure:
bare_video.resolve_video(segments)
assert "holds 2 video files" in str(folder_failure.value)
def test_a_lone_video_in_a_folder_is_that_video(tmp_path):
"""One file is unambiguous, however it is pointed at."""
folder = tmp_path / "single"
folder.mkdir()
only = write_video(folder / "clip.mp4")
assert bare_video.resolve_video(folder) == only
assert bare_video.resolve_video(only) == only
def test_something_that_is_not_a_video_is_refused_with_its_reason(tmp_path):
text = tmp_path / "notes.txt"
text.write_text("notes", encoding="utf-8")
with pytest.raises(bare_video.BareVideoError) as failure:
bare_video.resolve_video(text)
assert "not a video" in str(failure.value)
with pytest.raises(bare_video.BareVideoError):
bare_video.resolve_video(tmp_path / "absent.mp4")
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(bare_video.BareVideoError):
bare_video.resolve_video(empty)
def test_a_container_without_a_timeline_is_refused(tmp_path):
"""No frame count or rate means every ``t_rel_s`` would be a guess."""
broken = tmp_path / "broken.mp4"
broken.write_bytes(b"not a container")
with pytest.raises(bare_video.BareVideoError):
bare_video.probe(broken)
def test_a_profile_with_no_chain_is_listed_with_its_own_reason(tmp_path):
"""Nothing is hidden: every entry is offered, and the refused ones say why.
Two different refusals, from two owners — the SDK's validation and this app's chain
table — and both have to reach the operator as a sentence rather than as an entry that
is simply missing from the list.
"""
entries = [
_Entry(family="box", ref="shipped/box"),
_Entry(family="finger", ref="shipped/finger"),
_Entry(family="finger-mems", ref="shipped/finger-mems", name="MEMS"),
_Entry(
family="box",
ref="user/box",
name="Box (user)",
blocked="the SDK rejected this profile (failed)",
),
]
assert [entry.ref for entry in bare_video.profiles_for_video(entries)] == [
"shipped/box",
"shipped/finger",
"shipped/finger-mems",
"user/box",
]
assert bare_video.setup_blocked_reason(entries[0]) is None
assert bare_video.setup_blocked_reason(entries[1]) is None
assert "finger-mems" in str(bare_video.setup_blocked_reason(entries[2]))
assert bare_video.setup_blocked_reason(entries[3]) == (
"the SDK rejected this profile (failed)"
), "the SDK's own verdict must win over this app's chain limit"
# A folder no bundle could be read from has no profile behind it, so there is nothing
# to process with — and nothing to refuse *by* either.
assert bare_video.profiles_for_video([_Entry(has_profile=False)]) == []
with pytest.raises(bare_video.BareVideoError) as failure:
bare_video.build_workspace(
write_video(tmp_path / "clip.mp4"), profile=_Entry(family="uv_unroll")
)
assert "no chain" in str(failure.value)
# ------------------------------------------------------------------------- the files
def test_nothing_is_written_beside_the_operator_s_video(video, workspace_root):
"""The frame index is the app's derived data, and the video's folder is the operator's."""
before = _entry_set(video.parent)
workspace = _build(video)
assert _entry_set(video.parent) == before, "the workspace wrote into the video's folder"
assert workspace.directory.parent == workspace_root
assert sorted(path.name for path in workspace.directory.iterdir()) == [
"frames.csv",
"raw_0001.mp4",
"session.json",
]
def test_the_video_inside_the_workspace_is_the_operator_s_own_file(video, workspace_root):
"""A link when the filesystem allows one, and the copy is the same bytes when it does not."""
workspace = _build(video)
inside = workspace.directory / "raw_0001.mp4"
assert inside.read_bytes() == video.read_bytes()
mode = workspace.session["synthetic"]["video_in_workspace"]
assert mode in ("link", "copy", "existing")
# Rebuilding finds the same file rather than stacking a second copy.
again = _build(video)
assert again.session["synthetic"]["video_in_workspace"] == "existing"
def test_the_frame_index_is_written_as_the_columns_the_reader_wants(video, workspace_root):
workspace = _build(video)
with (workspace.directory / "frames.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
assert list(rows[0]) == list(bare_video.FRAME_COLUMNS)
assert len(rows) == FRAMES
assert rows[0]["frame_id"] == "1"
assert rows[0]["in_video"] == "1"
assert rows[0]["dropped"] == "0"
assert rows[0]["acquired_at"] == ""
# ------------------------------------------------------------------ the force channel
def test_a_frame_carries_its_force_sample_when_the_pass_collected_one():
"""Six values and two verdicts, appended last so an older reader still reads the frame."""
sample = ForceSample(
normal=1.5,
shear_x=-0.25,
shear_y=0.0,
normal_raw=1200.0,
shear_x_raw=-30.0,
shear_y_raw=0.0,
valid=True,
calibrated=True,
)
record = encode_frame(
frame_id=7,
t_rel_s=0.25,
grid_id=0,
dx=[1.0, 2.0],
dy=[0.0, 0.0],
magnitude=[1.0, 2.0],
active=[True, False],
force=sample,
)
decoded = decode_frame(record)
assert decoded.frame_id == 7
assert decoded.force == sample
assert decoded.force.newtons() == (1.5, -0.25, 0.0)
assert decoded.force.raw() == (1200.0, -30.0, 0.0)
assert decoded.force.calibrated is True
# A frame the pass did not collect force for carries none, and says so by absence.
without = decode_frame(
encode_frame(
frame_id=8,
t_rel_s=0.5,
grid_id=0,
dx=[1.0],
dy=[0.0],
magnitude=[1.0],
active=[True],
)
)
assert without.force is None
# A readout the chain could not make is stored as "no measurement", never as zero.
empty = ForceSample.from_reading(
{
"normal_force": float("nan"),
"shear_x": float("nan"),
"shear_y": float("nan"),
"valid": False,
"force_calibrated": False,
}
)
assert not empty.any_finite and not empty.valid
restored = decode_frame(
encode_frame(
frame_id=9,
t_rel_s=0.75,
grid_id=0,
dx=[1.0],
dy=[0.0],
magnitude=[1.0],
active=[True],
force=empty,
)
).force
assert restored is not None and not restored.any_finite and restored.valid is False
def test_depth_does_not_swallow_the_force_sample_that_follows_it():
"""Force is appended after depth. The depth codec must stop at its own payload."""
import numpy as np
sample = ForceSample(
normal=0.5,
shear_x=0.25,
shear_y=-0.125,
normal_raw=80.0,
shear_x_raw=10.0,
shear_y_raw=-4.0,
valid=True,
calibrated=False,
)
sparse = np.zeros((16, 16), dtype=np.float32)
sparse[2, 3] = 0.03
noise = np.linspace(0.0, 1.0, 16 * 16, dtype=np.float32).reshape(16, 16)
for depth in (sparse, noise, np.zeros((8, 8), dtype=np.float32)):
decoded = decode_frame(
encode_frame(
frame_id=3,
t_rel_s=0.1,
grid_id=0,
dx=[1.0],
dy=[0.0],
magnitude=[1.0],
active=[True],
depth=depth,
force=sample,
)
)
assert decoded.force == sample
assert decoded.depth is not None
restored = decoded.depth_map()
assert restored is not None and restored.shape == depth.shape