Skip to content

Commit 0058a10

Browse files
authored
Merge pull request #142 from mbel-tech/fatina-del-codice/beautiful-hypatia-wlxfol
feat: run 3-D projects (sub-project B1)
2 parents fd31f2d + 70d385a commit 0058a10

11 files changed

Lines changed: 767 additions & 92 deletions

File tree

‎tests/test_api.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ def test_user_override_ungates_a_session_tracked_without_identities() -> None:
339339

340340

341341
def test_explicit_identity_free_argument_wins_over_the_manifest() -> None:
342-
"""_run_one_session passes the answer it already holds, since it is
342+
"""_run_one_unit passes the answer it already holds, since it is
343343
keyed by ref.session_id and a reader-derived id may differ."""
344344
from track2data.api import Engine
345345

@@ -1490,7 +1490,7 @@ def _report_fingerprint(report: PreprocessReport) -> list[tuple]:
14901490
def test_run_attaches_preprocess_report_to_session_run_result(tmp_path: Path) -> None:
14911491
"""SessionRunResult.preprocess_report must carry the same PreprocessReport
14921492
that Engine.preprocess() produces for that session -- currently it's
1493-
computed but discarded before _run_one_session() builds the result."""
1493+
computed but discarded before _run_one_unit() builds the result."""
14941494
from track2data.api import Engine
14951495

14961496
session = _make_session(n_frames=10, n_animals=1)
@@ -1533,7 +1533,7 @@ def failing_preprocess(_session):
15331533

15341534

15351535
def test_run_keeps_preprocess_report_when_a_later_stage_fails(tmp_path: Path) -> None:
1536-
"""_run_one_session()'s except block covers import, preprocess, metrics,
1536+
"""_run_one_unit()'s except block covers import, preprocess, metrics,
15371537
payload build and export alike. When preprocessing succeeded and a *later*
15381538
stage blew up, the report exists and is exactly what the user needs to
15391539
diagnose the failure -- it must not be discarded along with the rest."""

‎tests/test_app/test_main_window.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ def test_task_failure_shows_a_dialog_with_message_and_traceback(
249249
) -> None:
250250
"""Engine.run is patched directly, not a sub-step: a session-level
251251
exception from import/preprocess/metrics/export is caught by
252-
Engine._run_one_session into SessionRunResult.error and still yields
252+
Engine._run_one_unit into SessionRunResult.error and still yields
253253
a *successful* RunResult, never a taskFinished(..., Exception) --
254254
that's the whole point of #7's fix, extending the same per-session
255255
resilience that already covered preprocess/metrics/export to import

‎tests/test_cli/test_3d_run.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""`run` and `validate` on a 3-D project with fusable pairs."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from click.testing import CliRunner
8+
9+
from tests.test_fusion.scene import build_scene, fusion_settings, pair
10+
from track2data.cli import cli
11+
from track2data.core.manifest import write as write_manifest
12+
13+
14+
def _project(tmp_path: Path, **kw: object) -> Path:
15+
path = tmp_path / "p3d.t2d.json"
16+
write_manifest(build_scene(tmp_path / "data", **kw), path) # type: ignore[arg-type]
17+
return path
18+
19+
20+
def test_run_3d_project_writes_pair_units_and_lists_the_skips(tmp_path: Path) -> None:
21+
out = tmp_path / "out"
22+
result = CliRunner().invoke(
23+
cli, ["run", str(_project(tmp_path, side2_fps=10.0)), "-o", str(out)]
24+
)
25+
assert result.exit_code == 0, result.output
26+
assert (out / "t1+s1").is_dir()
27+
assert not (out / "t2+s2").exists()
28+
assert "[skipped] t2: pair t2+s2: frame rates differ: 25 vs 10 fps" in result.output
29+
assert "[skipped] lone: not in a fusable pair" in result.output
30+
assert "Traceback" not in result.output
31+
32+
33+
def test_run_3d_project_with_no_fusable_pair_refuses(tmp_path: Path) -> None:
34+
pairs = [pair("t1", "s1", fusion=fusion_settings(frame_offset=1000))]
35+
out = tmp_path / "out"
36+
result = CliRunner().invoke(
37+
cli, ["run", str(_project(tmp_path, pairs=pairs)), "-o", str(out)]
38+
)
39+
assert result.exit_code == 2
40+
assert "[error] no pair is ready to fuse: pair t1+s1: " in result.output
41+
assert "Running" not in result.output
42+
assert not out.exists()
43+
44+
45+
def test_validate_3d_reports_pair_issues_and_passes(tmp_path: Path) -> None:
46+
result = CliRunner().invoke(cli, ["validate", str(_project(tmp_path, side2_fps=10.0))])
47+
assert result.exit_code == 0, result.output
48+
assert "[issue] pair t2+s2: frame rates differ: 25 vs 10 fps" in result.output
49+
assert "[issue] session lone: not in a fusable pair" in result.output
50+
assert "ready to run" in result.output
51+
52+
53+
def test_validate_3d_fails_when_no_pair_fuses(tmp_path: Path) -> None:
54+
pairs = [pair("t1", "s1", fusion=fusion_settings(frame_offset=1000))]
55+
result = CliRunner().invoke(cli, ["validate", str(_project(tmp_path, pairs=pairs))])
56+
assert result.exit_code == 1
57+
assert "[issue] no pair is ready to fuse: pair t1+s1: " in result.output
58+
assert "ready to run" not in result.output

‎tests/test_fusion/scene.py‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""A small 3-D project on disk: real idtracker.ai v5 session folders with known depths.
2+
3+
The folders are read by a built-in reader, so a spawned worker process (which sees none of the
4+
tests' monkeypatches) runs exactly what the calling process runs.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
from datetime import UTC, datetime
11+
from pathlib import Path
12+
13+
import numpy as np
14+
15+
from track2data.core.models import (
16+
CalibrationConfig,
17+
FusionSettings,
18+
MetricSelection,
19+
ProjectManifest,
20+
ProjectMode,
21+
SessionRef,
22+
ViewPair,
23+
)
24+
25+
N_FRAMES = 100
26+
FPS = 25.0
27+
#: Side-view rows of fish 0, 1, 2: depth 0.25 / 0.5 / 0.75 between surface row 100 and floor 300.
28+
SIDE_ROWS = (150.0, 200.0, 250.0)
29+
DEPTHS = (0.25, 0.5, 0.75)
30+
TANK_CM = 20.0
31+
LABELS = ("0", "1", "2") # the v5 reader has no labels, so fish are named by column
32+
33+
34+
def write_v5_session(folder: Path, xy: np.ndarray, *, fps: float = FPS) -> Path:
35+
"""Write ``xy`` (n_frames, n_animals, 2) as an idtracker.ai v5 session folder."""
36+
(folder / "trajectories").mkdir(parents=True)
37+
np.save(folder / "trajectories" / "trajectories.npy", xy)
38+
n_frames, n_animals = xy.shape[:2]
39+
np.save(
40+
folder / "video_object.npy",
41+
{
42+
"fps": fps,
43+
"frame_number": n_frames,
44+
"height": 400,
45+
"width": 400,
46+
"paths_to_video_files": ["video.mp4"],
47+
},
48+
)
49+
(folder / "session_progress.json").write_text(
50+
json.dumps(
51+
{
52+
"session_name": folder.name,
53+
"number_of_animals": n_animals,
54+
"frames_per_second": fps,
55+
"video_height": 400,
56+
"video_width": 400,
57+
"status": "finished",
58+
}
59+
),
60+
encoding="utf-8",
61+
)
62+
return folder
63+
64+
65+
def top_xy(shift: float = 0.0) -> np.ndarray:
66+
t = np.arange(N_FRAMES, dtype=float)
67+
out = np.empty((N_FRAMES, 3, 2))
68+
for k in range(3):
69+
out[:, k, 0] = 20 + 10 * k + t + shift
70+
out[:, k, 1] = 50 + 20 * k
71+
return out
72+
73+
74+
def side_xy(shift: float = 0.0) -> np.ndarray:
75+
out = top_xy(shift)
76+
for k in range(3):
77+
out[:, k, 1] = SIDE_ROWS[k]
78+
return out
79+
80+
81+
def fusion_settings(**kw: object) -> FusionSettings:
82+
base: dict[str, object] = {"surface_row": 100.0, "floor_row": 300.0, "tank_height_cm": TANK_CM}
83+
base.update(kw)
84+
return FusionSettings(**base) # type: ignore[arg-type]
85+
86+
87+
def pair(top: str, side: str, *, fusion: FusionSettings | None = None) -> ViewPair:
88+
return ViewPair(
89+
top_session_id=top,
90+
side_session_id=side,
91+
fish_map={lab: lab for lab in LABELS},
92+
fusion=fusion if fusion is not None else fusion_settings(),
93+
)
94+
95+
96+
def build_scene(
97+
base: Path,
98+
*,
99+
side2_fps: float = FPS,
100+
pairs: list[ViewPair] | None = None,
101+
with_lone: bool = True,
102+
) -> ProjectManifest:
103+
"""Two top/side pairs (t1+s1, t2+s2) and, with ``with_lone``, a top session in no pair.
104+
105+
``side2_fps`` other than ``FPS`` makes the second pair fail to fuse (frame rates differ).
106+
"""
107+
write_v5_session(base / "t1", top_xy())
108+
write_v5_session(base / "s1", side_xy())
109+
write_v5_session(base / "t2", top_xy(5.0))
110+
write_v5_session(base / "s2", side_xy(5.0), fps=side2_fps)
111+
ids = ["t1", "s1", "t2", "s2"]
112+
if with_lone:
113+
write_v5_session(base / "lone", top_xy(9.0))
114+
ids.append("lone")
115+
now = datetime.now(tz=UTC)
116+
return ProjectManifest(
117+
project_name="scene3d",
118+
created_at=now,
119+
updated_at=now,
120+
sessions=[SessionRef(session_id=i, folder=base / i, sha256="") for i in ids],
121+
mode=ProjectMode(dimension="3d", layout="two_videos"),
122+
calibration=CalibrationConfig(mode="scalar", px_per_cm=10.0),
123+
metrics=MetricSelection(individual=["IL-1", "IL-15"], group=[], zone=[], diagnostic=[]),
124+
view_pairs=pairs if pairs is not None else [pair("t1", "s1"), pair("t2", "s2")],
125+
)

0 commit comments

Comments
 (0)