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