Skip to content

Commit ca83cc5

Browse files
authored
Merge pull request #160 from mbel-tech/fatina-del-codice/sp5-item4-handedness
2 parents 731e6cd + 0a5e44e commit ca83cc5

6 files changed

Lines changed: 52 additions & 9 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 files read with `vertical_axis = y` no longer have a mirrored horizontal plane.** The two axes that are not vertical were taken in file order, which for `y` gives `(x, z)`, the mirror image of the right-handed `(z, x)`. Distances, speeds and every magnitude were unaffected, but any signed quantity was reflected: **IL-11's `mean_heading_rad` (θ became π/2 − θ) and the sign of `left_right_turn_bias`** for such files. The plane is now taken in cyclic order after the vertical axis (`z`: x, y; `x`: y, z; `y`: z, x), so it is right-handed seen from above whichever axis is vertical. `vertical_axis = z` (the default) and `x` are unchanged; **only files read with `y` change**, and only those two signed values. `flip` still reverses the vertical axis alone, which also mirrors the view from above; that is a documented limit, not changed here.
4748
- **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.
4849
- **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.
4950
- **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.

‎docs/guide/USER_GUIDE.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,10 @@ before relying on them.
321321
`target_unit` (the project's length unit, default `cm`), `keypoint` (which keypoint stands for the
322322
animal; never an average), `likelihood_cutoff` (Anipose only, applied to the `_score` columns; it
323323
needs a score for every keypoint, otherwise set it to 0), `vertical_axis` (`x`, `y` or `z`, default
324-
`z`: which axis of the file points up), `flip` (tick when it points down) and `keep_skeleton`.
324+
`z`: which axis of the file points up), `flip` (tick when it points down) and `keep_skeleton`. The
325+
other two axes become the horizontal plane in cyclic order after the vertical one (`z`: x, y; `x`: y, z;
326+
`y`: z, x), which keeps the plane right-handed seen from above, so headings and turn directions have the
327+
same sense whichever axis the file calls up. `flip` reverses the vertical axis only.
325328
- **Units are converted when the file is read**, from `unit` to `target_unit`. The first native file
326329
you add sets the project's calibration for you: **Session calibration**, the unit label set to the
327330
target unit and marked confirmed. Files added later must use the same target unit; a different one

‎tests/test_readers/test_anipose.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ def test_anipose_vertical_axis_and_flip(tmp_path: Path) -> None:
9797
path = write_anipose_csv(tmp_path / "a.csv", ["a"], 2, xyz=xyz)
9898
s = read(path, target_unit="mm", vertical_axis="y")
9999
assert s.raw_z is not None
100-
np.testing.assert_array_equal(s.raw_xy[:, 0, :], [[1, 3], [4, 6]])
100+
# vertical y: the horizontal plane is (z, x), right-handed seen from above
101+
np.testing.assert_array_equal(s.raw_xy[:, 0, :], [[3, 1], [6, 4]])
101102
np.testing.assert_array_equal(s.raw_z[:, 0], [2, 5])
102103
f = read(path, target_unit="mm", vertical_axis="y", flip=True)
103104
assert f.raw_z is not None

‎tests/test_readers/test_deeplabcut_3d.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ def test_vertical_axis_and_flip(tmp_path: Path) -> None:
8787
path = write_dlc3d_csv(tmp_path / "a.csv", ["a"], 2, xyz=xyz)
8888
s = read(path, target_unit="mm", vertical_axis="y")
8989
assert s.raw_z is not None and s.keypoints is not None
90-
np.testing.assert_array_equal(s.raw_xy[:, 0, :], [[1, 3], [4, 6]])
90+
# vertical y: the horizontal plane is (z, x), right-handed seen from above
91+
np.testing.assert_array_equal(s.raw_xy[:, 0, :], [[3, 1], [6, 4]])
9192
np.testing.assert_array_equal(s.raw_z[:, 0], [2, 5])
9293
f = read(path, target_unit="mm", vertical_axis="y", flip=True)
9394
assert f.raw_z is not None and f.keypoints is not None

‎tests/test_readers/test_native3d.py‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,43 @@ def test_convert_units_is_exact_for_common_cases() -> None:
3636

3737

3838
def test_horizontal_axes() -> None:
39+
# cyclic order after the vertical axis: x -> (y, z), y -> (z, x), z -> (x, y)
3940
assert horizontal_axes("z") == (0, 1)
40-
assert horizontal_axes("y") == (0, 2)
41+
assert horizontal_axes("y") == (2, 0)
4142
assert horizontal_axes("x") == (1, 2)
4243
with pytest.raises(ValueError):
4344
horizontal_axes("w")
4445

4546

47+
@pytest.mark.parametrize("vertical", ["x", "y", "z"])
48+
def test_the_horizontal_plane_is_right_handed_with_the_vertical_axis_up(vertical: str) -> None:
49+
"""first x second = up, so a counterclockwise turn seen from above stays counterclockwise."""
50+
first, second = horizontal_axes(vertical)
51+
unit = np.eye(3)
52+
np.testing.assert_array_equal(
53+
np.cross(unit[first], unit[second]), unit["xyz".index(vertical)]
54+
)
55+
56+
57+
@pytest.mark.parametrize(
58+
("vertical", "permutation"), [("z", (0, 1, 2)), ("x", (2, 0, 1)), ("y", (1, 2, 0))]
59+
)
60+
def test_a_proper_rotation_of_the_scene_leaves_the_horizontal_track_unchanged(
61+
vertical: str, permutation: tuple[int, int, int]
62+
) -> None:
63+
"""The same physical track, with up on each axis in turn (cyclic permutations of the file
64+
axes are proper rotations): the horizontal track the reader returns must not change, not
65+
even by a mirror, or every signed heading and turn would."""
66+
rng = np.random.default_rng(0)
67+
base = rng.normal(size=(12, 1, 1, 3)) # (frames, animals, keypoints, xyz), z up
68+
rotated = base[..., list(permutation)]
69+
red, z = reduce_native(
70+
rotated, ["a"], None, keypoint="a", cutoff=None, vertical_axis=vertical, flip=False
71+
)
72+
np.testing.assert_array_equal(red.raw_xy[:, 0, :], base[:, 0, 0, :2])
73+
np.testing.assert_array_equal(z[:, 0], base[:, 0, 0, 2])
74+
75+
4676
def test_native_parameters_order_and_requirements() -> None:
4777
params = native_parameters("mm")
4878
assert [p.name for p in params] == [
@@ -71,9 +101,9 @@ def test_reduce_native_picks_the_vertical_coordinate() -> None:
71101
red, z = reduce_native(
72102
xyz, ["a", "b"], None, keypoint="b", cutoff=None, vertical_axis="y", flip=True
73103
)
74-
np.testing.assert_array_equal(red.raw_xy[:, 0, :], xyz[:, 0, 1, [0, 2]])
104+
np.testing.assert_array_equal(red.raw_xy[:, 0, :], xyz[:, 0, 1, [2, 0]])
75105
np.testing.assert_array_equal(z[:, 0], -xyz[:, 0, 1, 1])
76-
assert red.selection.plane == ("x", "z")
106+
assert red.selection.plane == ("z", "x")
77107

78108

79109
def _assemble(**over):

‎track2data/readers/native3d.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,18 @@ def convert_units(values: np.ndarray, unit: str, target: str) -> np.ndarray:
8989

9090

9191
def horizontal_axes(vertical_axis: str) -> tuple[int, int]:
92-
"""The two file axes that are not vertical, in file order."""
92+
"""The two file axes that are not vertical, in cyclic order after the vertical one.
93+
94+
``z`` gives ``(x, y)``, ``x`` gives ``(y, z)`` and ``y`` gives ``(z, x)``. In that order the
95+
first axis crossed with the second is the vertical axis, so the plane is right-handed seen
96+
from above: a counterclockwise turn stays one, and the signed headings and turn directions
97+
keep their sense whichever axis the file calls vertical. (File order would give ``(x, z)``
98+
for ``y``, a mirror image.)
99+
"""
93100
if vertical_axis not in AXES:
94101
raise ValueError(f"vertical_axis must be one of {list(AXES)}, got {vertical_axis!r}")
95-
first, second = (i for i in range(3) if AXES[i] != vertical_axis)
96-
return first, second
102+
up = AXES.index(vertical_axis)
103+
return (up + 1) % 3, (up + 2) % 3
97104

98105

99106
def union_keypoints(keypoint_sets: Iterable[Sequence[str]]) -> tuple[str, ...]:

0 commit comments

Comments
 (0)