Skip to content

Commit 731e6cd

Browse files
authored
Merge pull request #159 from mbel-tech/fatina-del-codice/sp5-item3-vertical-axis
2 parents be7e7fd + 6938881 commit 731e6cd

5 files changed

Lines changed: 139 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
4444

4545
### Fixed
4646

47+
- **Native 3-D projects refuse files on different vertical axes.** The project has one water column, entered on "the file's own vertical axis", but `vertical_axis` was only recorded in provenance, so two files read with different axes shared a surface and floor that meant different things for each. The import rule that already refuses mixed units now also refuses a file whose `vertical_axis` differs from the project's (`NATIVE_3D_AXIS_MISMATCH`, naming both axes and the fix), whether it comes in with the same batch, was already in the project, or is a session just read. A missing option counts as `z`, the default; `flip` may still differ because it is compensated per session. The rule runs whenever a session is read, as the unit rule does, so an existing project whose files already disagree is refused with the same message until they are re-added on one axis. The origin of the axis cannot be checked from the files, so the water column still assumes one shared origin.
4748
- **Native 3-D files: the water column now bounds every 3-D metric.** It already excluded out-of-column samples from IL-15's depth statistics, but IL-16, IL-17 and GL-16 read the raw z, so a mis-triangulated or out-of-tank sample (a reflection, a fish out of the water) stayed in the path length and speed. One helper, `vertical_cm`, now gives the vertical coordinate to all of them: a sample outside the water column is NaN there, so a step or frame that needs it is dropped (nothing is clipped). The exported `z_cm` column stays the file's own coordinate. **IL-16, IL-17 and GL-16 change for native 3-D projects that set a water column and have samples outside it**; without a column, or with every sample inside, they are unchanged. The Calibration page says so.
4849
- **IL-15 on fused 3-D projects: `frac_outside_extent` no longer grows with top-view gaps.** The share was counted over samples that also had a depth, which needs the top view as well, so a fish the top view lost for half the video reported twice the true share of side-view samples outside the water column (0.20 against 0.10 in the review's probe), and the warning blamed the fusion settings. The fused session now records where the side view had a position (`depth_side_valid`) and the share is over those samples. A side sample with a y but no x now gives a depth (x is only used for the view-agreement check). Native 3-D files are unchanged.
4950
- **Native 3-D files: the vertical axis is now preprocessed like the horizontal plane.** `z` was laid out and then left raw, so a smoothed x/y was combined with an unsmoothed z in IL-16 and IL-17 and no setting could reduce the vertical share of the 3-D path. It now goes through the same steps and settings as x/y: blanked where a jump was replaced, bridged across tracking intervals, gap-filled (same `max_gap_frames`, same limits) and smoothed (same method, window and polyorder). On a synthetic helix with 2 mm noise (true 3-D path 251.5 cm, true vertical path 15.96 cm) the vertical contribution to IL-16 was 129.14 cm at every smoothing setting; it is now 60.09 cm at window 5 (default) and 16.49 cm at window 41, and IL-16 is 275.78 cm and 252.06 cm (was 307.66 and 294.20). **Native 3-D IL-16 and `max_speed_3d_cm_s` will be lower than before on noisy files**; the sensitivity sweep now varies the vertical half too. Cached preprocessing is rebuilt once (cache schema 7); `project_hash` is unchanged.

‎docs/guide/USER_GUIDE.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,10 @@ before relying on them.
325325
- **Units are converted when the file is read**, from `unit` to `target_unit`. The first native file
326326
you add sets the project's calibration for you: **Session calibration**, the unit label set to the
327327
target unit and marked confirmed. Files added later must use the same target unit; a different one
328-
is refused (`NATIVE_3D_UNIT_MISMATCH`, naming both units). The `_cm` column suffix is nominal, as for
329-
any project calibrated in another unit; the run's `README.md` names the real unit.
328+
is refused (`NATIVE_3D_UNIT_MISMATCH`, naming both units). They must also share the **vertical
329+
axis**, because one water column serves every session: a file read with another `vertical_axis` is
330+
refused (`NATIVE_3D_AXIS_MISMATCH`, naming both axes); `flip` may differ. The `_cm` column suffix is
331+
nominal, as for any project calibrated in another unit; the run's `README.md` names the real unit.
330332
- **No mixing.** A project holds pixel-tracked sessions or native 3-D sessions, not both, and native
331333
files cannot join a 3-D (fused) project: the add is refused with `NATIVE_3D_MIXED`, naming the
332334
session and both kinds.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Native sessions of one project must share a vertical axis: one water column serves them all."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from track2data.api import Engine
10+
from track2data.core.errors import ImportError_
11+
from track2data.core.models import SessionRef
12+
13+
from .test_project_rules import _manifest, _native
14+
15+
16+
def _ref(session_id: str, axis: str | None = None, **extra) -> SessionRef:
17+
options: dict = {"fps": 25.0, "unit": "mm", **extra}
18+
if axis is not None:
19+
options["vertical_axis"] = axis
20+
return SessionRef(
21+
session_id=session_id,
22+
folder=Path("/tmp") / session_id,
23+
sha256="",
24+
reader="anipose",
25+
reader_options=options,
26+
)
27+
28+
29+
def _with_axis(session, axis: str):
30+
attrs = {"native_3d": {"vertical_axis": axis, "flip": False}}
31+
return session.model_copy(update={"raw_attrs": attrs})
32+
33+
34+
def test_a_batch_with_two_vertical_axes_is_refused_naming_both() -> None:
35+
engine = Engine(_manifest([]))
36+
with pytest.raises(ImportError_) as caught:
37+
engine.check_native_refs([_ref("a", "z"), _ref("b", "y")])
38+
assert caught.value.code == "NATIVE_3D_AXIS_MISMATCH"
39+
message = str(caught.value)
40+
assert "'a'" in message and "'b'" in message and "'z'" in message and "'y'" in message
41+
42+
43+
def test_a_file_on_another_axis_than_the_projects_is_refused() -> None:
44+
engine = Engine(_manifest([_ref("a", "z")]))
45+
with pytest.raises(ImportError_) as caught:
46+
engine.check_native_refs([_ref("b", "x")])
47+
assert caught.value.code == "NATIVE_3D_AXIS_MISMATCH"
48+
assert "'a'" in str(caught.value) and "'b'" in str(caught.value)
49+
50+
51+
def test_the_default_axis_is_z_when_the_option_is_not_saved() -> None:
52+
engine = Engine(_manifest([_ref("a")])) # no vertical_axis saved: z
53+
engine.check_native_refs([_ref("b", "z")]) # does not raise
54+
with pytest.raises(ImportError_):
55+
engine.check_native_refs([_ref("c", "y")])
56+
57+
58+
def test_the_same_axis_with_a_different_flip_is_accepted() -> None:
59+
engine = Engine(_manifest([_ref("a", "z", flip=False)]))
60+
engine.check_native_refs([_ref("b", "z", flip=True)])
61+
62+
63+
def test_a_read_session_on_another_axis_is_refused() -> None:
64+
engine = Engine(_manifest([_ref("a", "z")]))
65+
with pytest.raises(ImportError_) as caught:
66+
engine.check_native_import(_with_axis(_native("b"), "y"))
67+
assert caught.value.code == "NATIVE_3D_AXIS_MISMATCH"
68+
69+
70+
def test_a_read_session_on_the_projects_axis_is_accepted() -> None:
71+
engine = Engine(_manifest([_ref("a", "y")]))
72+
engine.check_native_import(_with_axis(_native("b"), "y"))
73+
74+
75+
def test_a_session_without_axis_attributes_counts_as_z() -> None:
76+
engine = Engine(_manifest([_ref("a", "z")]))
77+
engine.check_native_import(_native("b")) # raw_attrs carries nothing: z
78+
79+
80+
def test_the_refusal_says_how_to_fix_it() -> None:
81+
engine = Engine(_manifest([_ref("a", "z")]))
82+
with pytest.raises(ImportError_) as caught:
83+
engine.check_native_refs([_ref("b", "y")])
84+
assert "vertical_axis" in (caught.value.remediation or "")
85+
86+
87+
def test_a_project_whose_files_already_disagree_is_refused_when_one_is_read() -> None:
88+
"""The rule runs on every import, as the unit rule does, so an existing mixed project says
89+
why instead of computing a water column that means two things."""
90+
engine = Engine(_manifest([_ref("a", "z"), _ref("b", "y")]))
91+
with pytest.raises(ImportError_) as caught:
92+
engine.check_native_import(_native("a"))
93+
assert caught.value.code == "NATIVE_3D_AXIS_MISMATCH"

‎track2data/api.py‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,20 +155,25 @@ class _ImportFact:
155155
reader: str | None
156156
native: bool
157157
unit: str | None
158+
vertical_axis: str | None = None
158159

159160
@classmethod
160161
def of_session(cls, session: Session) -> _ImportFact:
161-
from track2data.readers.native3d import NATIVE_3D_READERS
162+
from track2data.readers.native3d import DEFAULT_VERTICAL_AXIS, NATIVE_3D_READERS
162163

163164
native = session.reader in NATIVE_3D_READERS or not session.has_pixel_frame
164-
return cls(session.session_id, session.reader, native, session.coordinate_unit)
165+
attrs = (session.raw_attrs or {}).get("native_3d") or {}
166+
axis = str(attrs.get("vertical_axis") or DEFAULT_VERTICAL_AXIS) if native else None
167+
return cls(session.session_id, session.reader, native, session.coordinate_unit, axis)
165168

166169
@classmethod
167170
def of_ref(cls, ref: SessionRef) -> _ImportFact:
168-
from track2data.readers.native3d import ref_target_unit
171+
from track2data.readers.native3d import ref_target_unit, ref_vertical_axis
169172

170173
unit = ref_target_unit(ref)
171-
return cls(ref.session_id, ref.reader, unit is not None, unit)
174+
return cls(
175+
ref.session_id, ref.reader, unit is not None, unit, ref_vertical_axis(ref)
176+
)
172177

173178

174179
def _view_note(psess: PreprocessedSession) -> str:
@@ -612,6 +617,28 @@ def _native_rule(
612617
return None
613618
cfg = self._manifest.calibration
614619
first = natives[0]
620+
# One water column serves every session, so they must share the vertical axis it is
621+
# entered on (flip may differ: it is compensated per session).
622+
from track2data.readers.native3d import ref_vertical_axis
623+
624+
axes = [
625+
(r.session_id, ref_vertical_axis(r))
626+
for r in existing
627+
if r.reader in NATIVE_3D_READERS
628+
]
629+
axes += [(f.session_id, f.vertical_axis) for f in natives]
630+
axis_ref = axes[0]
631+
for other_id, other_axis in axes[1:]:
632+
if other_axis != axis_ref[1]:
633+
raise ImportError_(
634+
f"Sessions {axis_ref[0]!r} and {other_id!r} have different vertical axes, "
635+
f"{axis_ref[1]!r} and {other_axis!r}; the project's water column is entered "
636+
"on one axis",
637+
code="NATIVE_3D_AXIS_MISMATCH",
638+
subject=other_id,
639+
remediation=f"Add it again with vertical_axis = {axis_ref[1]!r}, or add the "
640+
"files to projects of their own.",
641+
)
615642
for other in natives[1:]:
616643
if other.unit != first.unit:
617644
raise ImportError_(

‎track2data/readers/native3d.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
#: The ``target_unit`` option's default: the project length unit a file is converted to when the
4242
#: user chose none.
4343
DEFAULT_TARGET_UNIT = "cm"
44+
DEFAULT_VERTICAL_AXIS = "z"
4445
AXES: tuple[str, ...] = ("x", "y", "z")
4546

4647

@@ -53,6 +54,15 @@ def manifest_native_3d(manifest: Any) -> bool:
5354
return any(ref.reader in NATIVE_3D_READERS for ref in manifest.sessions)
5455

5556

57+
def ref_vertical_axis(ref: Any) -> str | None:
58+
"""The vertical axis a native entry's file is read with, from its saved options (None for an
59+
entry of another reader). Confirmed entries save only the options that were given, so a
60+
missing one is the default."""
61+
if ref.reader not in NATIVE_3D_READERS:
62+
return None
63+
return str(ref.reader_options.get("vertical_axis") or DEFAULT_VERTICAL_AXIS)
64+
65+
5666
def ref_target_unit(ref: Any) -> str | None:
5767
"""The unit a native entry's file is converted to, from its saved options (None for an entry
5868
of another reader). Confirmed entries save only the options that were given, so a missing

0 commit comments

Comments
 (0)