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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user