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,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