Skip to content

Commit 7763523

Browse files
authored
Merge pull request #147 from mbel-tech/fatina-del-codice/elegant-noether-ckmrsl
fix(preprocess): put native 3-D z through gap-fill and smoothing (SP1)
2 parents 0b04ecf + 9929f4c commit 7763523

9 files changed

Lines changed: 267 additions & 13 deletions

File tree

‎CHANGELOG.md‎

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

3737
### Fixed
3838

39+
- **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.
3940
- **idtracker.ai bare exports.** A CSV bundle written without a time column (idtracker.ai omits it when the frame rate is unknown) no longer crashes with a bare `ValueError`: the column count decides whether a leading time column is present, for `trajectories.csv` and `id_probabilities.csv` alike. `id_probabilities` that do not match the trajectories (frames or animals) are now refused with `IDT_SHAPE_MISMATCH` instead of passing silently. The idtracker.ai reader gains optional `fps`, `width_px` and `height_px` options for exports that record no video size or frame rate; they fill gaps only, behind the trajectory file and `session.json`. A folder whose only trajectory format is a pickle and has no consent now fails with `IDT_PICKLE_REFUSED` (was `IDT_FORMAT_AMBIGUOUS`).
4041
- **The test run no longer crashes at exit, and runs about three times faster.** After every test had passed, the Qt
4142
tests intermittently ended with "QObject: shared QObject was deleted directly" and a segfault (exit 139), which

‎tests/test_integration/test_native_3d.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ def test_il15_with_the_water_column_counts_the_outside_values(
182182
their depth is NaN and they are counted, never clipped: 2 of 11 outside; the other nine
183183
frames (z 10 to 19.6) average 14.8 cm = 0.74."""
184184
manifest = _manifest(project, WaterColumn(surface_z=0.0, floor_z=20.0))
185+
# the scene sits exactly on the surface (z = 0 at frame 0); smoothing's rounding noise
186+
# (-3e-16) would put that one sample outside, which is not what this test is about
187+
no_smoothing = manifest.preprocess.smoothing.model_copy(update={"enabled": False})
188+
manifest = manifest.model_copy(
189+
update={"preprocess": manifest.preprocess.model_copy(update={"smoothing": no_smoothing})}
190+
)
185191
Engine(manifest).run(tmp_path / "o", exporters=["csv_long", "csv_wide"])
186192
one = _long(tmp_path / "o", "fish1")
187193
assert _value(one, "IL-15", "mean_depth_fraction") == pytest.approx(0.3, abs=1e-9)

‎tests/test_native3d/test_core.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,13 @@ def test_pipeline_lays_out_z_with_xy() -> None:
108108
assert psess.separator_mask.any()
109109
assert np.isnan(psess.z[psess.separator_mask]).all()
110110
assert gap.raw_z is not None
111-
np.testing.assert_array_equal(psess.z[psess.tracked_mask], gap.raw_z)
111+
# z now goes through the same smoothing as xy; a straight line is unchanged by it
112+
np.testing.assert_allclose(psess.z[psess.tracked_mask], gap.raw_z, atol=1e-9)
112113

113114
whole = _session_z(None)
114115
plain = run(whole, PreprocessConfig())
115116
assert whole.raw_z is not None
116-
np.testing.assert_array_equal(plain.z, whole.raw_z)
117+
np.testing.assert_allclose(plain.z, whole.raw_z, atol=1e-9)
117118
assert plain.z is not whole.raw_z
118119

119120

@@ -179,15 +180,15 @@ def test_apply_water_column_non_finite_z_is_missing_not_outside() -> None:
179180
assert out.depth_outside is not None and out.depth_outside.tolist() == [0]
180181

181182

182-
def test_z_through_a_bridged_gap_is_nan_not_interpolated() -> None:
183+
def test_z_through_a_bridged_gap_is_bridged_like_xy() -> None:
183184
session = _session_z([(0, 10), (12, 22)])
184185
cfg = PreprocessConfig(gap_fill=GapFillCfg(across_tracking_intervals=True))
185186
psess = run(session, cfg, bridge_allowed=True)
186187
assert psess.z is not None and psess.tracked_mask is not None
187188
assert psess.separator_mask is not None
188189
bridged = ~psess.tracked_mask & ~psess.separator_mask
189190
assert bridged.any()
190-
assert np.isnan(psess.z[bridged]).all()
191+
assert np.isfinite(psess.z[bridged]).all()
191192
assert np.isfinite(psess.xy[bridged]).any()
192193

193194

@@ -221,7 +222,7 @@ def test_fuse_refuses_native_3d_sessions() -> None:
221222
fuse(top, side, pair, same_video=False)
222223

223224

224-
def test_a_replaced_jump_also_blanks_z() -> None:
225+
def test_a_replaced_jump_also_re_fills_z() -> None:
225226
n = 40
226227
session = _session_z(None, n)
227228
xy = np.zeros((n, 1, 2))
@@ -233,6 +234,7 @@ def test_a_replaced_jump_also_blanks_z() -> None:
233234
out = run(session, config)
234235
assert out.jump_replaced is not None and out.jump_replaced[20, 0]
235236
assert out.z is not None
236-
assert np.isnan(out.z[20, 0])
237-
assert np.isfinite(out.z[10, 0]) and out.z[10, 0] == session.raw_z[10, 0]
237+
# the glitch's own z is dropped and rebuilt from its neighbours (a line here: 120)
238+
assert out.z[20, 0] == pytest.approx(120.0, abs=1e-9)
239+
assert np.isfinite(out.z[10, 0]) and out.z[10, 0] == pytest.approx(110.0, abs=1e-9)
238240
assert session.raw_z[20, 0] == 120.0 # the session's own array is untouched
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""The vertical axis of a native 3-D file goes through the same preprocessing as xy."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import numpy as np
8+
9+
from track2data.core.models import (
10+
GapFillCfg,
11+
IdSwitchCfg,
12+
JumpCfg,
13+
PreprocessConfig,
14+
Session,
15+
SmoothCfg,
16+
VideoInfo,
17+
)
18+
from track2data.preprocess.pipeline import run
19+
20+
21+
def _session(xy: np.ndarray, z: np.ndarray, intervals=None) -> Session:
22+
return Session(
23+
velocity_threshold_px_frame=20.0,
24+
session_id="s",
25+
folder=Path("/tmp/s"),
26+
reader="test",
27+
video=VideoInfo(fps=30.0, n_frames=len(xy), width_px=0, height_px=0),
28+
n_animals=1,
29+
trajectory_variant="with_gaps",
30+
has_stable_identities=True,
31+
raw_xy=xy,
32+
raw_z=z.reshape(len(z), 1),
33+
has_pixel_frame=False,
34+
coordinate_unit="cm",
35+
tracking_intervals=intervals,
36+
)
37+
38+
39+
def _cfg(*, gap=False, jump=False, smooth=None, across=False, max_gap=30) -> PreprocessConfig:
40+
return PreprocessConfig(
41+
gap_fill=GapFillCfg(
42+
enabled=gap, max_gap_frames=max_gap, across_tracking_intervals=across
43+
),
44+
jump=JumpCfg(
45+
enabled=jump, method="idtracker_velocity_threshold", replacement="linear_interp"
46+
),
47+
identity_switch=IdSwitchCfg(enabled=False),
48+
smoothing=smooth or SmoothCfg(enabled=False),
49+
)
50+
51+
52+
def _line(n: int = 60):
53+
xy = np.zeros((n, 1, 2))
54+
xy[:, 0, 0] = np.arange(n, dtype=float)
55+
z = np.arange(n, dtype=float) * 0.5
56+
return xy, z
57+
58+
59+
def _path(z: np.ndarray) -> float:
60+
return float(np.nansum(np.abs(np.diff(z))))
61+
62+
63+
def test_z_is_smoothed_with_the_xy_settings() -> None:
64+
rng = np.random.default_rng(0)
65+
xy, _ = _line(300)
66+
z = np.full(300, 5.0) + rng.normal(0.0, 0.2, 300)
67+
cfg = _cfg(smooth=SmoothCfg(enabled=True, method="savgol", window=25, polyorder=2))
68+
69+
psess = run(_session(xy, z), cfg)
70+
71+
assert psess.z is not None
72+
# a flat true track: smoothing must remove most of the noise path, not leave it raw
73+
assert _path(psess.z[:, 0]) < 0.25 * _path(z)
74+
75+
76+
def test_z_is_unchanged_when_smoothing_and_gap_fill_are_off() -> None:
77+
xy, z = _line()
78+
z = z + np.random.default_rng(1).normal(0, 0.1, len(z))
79+
psess = run(_session(xy, z), _cfg())
80+
assert psess.z is not None
81+
np.testing.assert_array_equal(psess.z[:, 0], z)
82+
83+
84+
def test_short_z_gap_is_filled_like_xy() -> None:
85+
xy, z = _line()
86+
xy[10:15] = np.nan
87+
z[10:15] = np.nan
88+
psess = run(_session(xy, z), _cfg(gap=True))
89+
assert psess.z is not None
90+
np.testing.assert_allclose(psess.z[10:15, 0], np.arange(10, 15) * 0.5)
91+
92+
93+
def test_z_gap_longer_than_max_stays_missing() -> None:
94+
xy, z = _line()
95+
xy[10:20] = np.nan
96+
z[10:20] = np.nan
97+
psess = run(_session(xy, z), _cfg(gap=True, max_gap=5))
98+
assert psess.z is not None
99+
assert np.isnan(psess.z[10:20, 0]).all()
100+
101+
102+
def test_a_z_gap_xy_does_not_have_is_not_invented_beyond_the_anchors() -> None:
103+
"""The leading edge has no left anchor, so it stays missing exactly as xy would."""
104+
xy, z = _line()
105+
z[:3] = np.nan
106+
psess = run(_session(xy, z), _cfg(gap=True))
107+
assert psess.z is not None
108+
assert np.isnan(psess.z[:3, 0]).all()
109+
110+
111+
def test_jump_replaced_z_is_refilled_not_kept_raw() -> None:
112+
xy, z = _line()
113+
xy[30, 0, 0] += 500.0 # triangulation glitch in the horizontal plane
114+
z[30] += 40.0 # and in the vertical one
115+
cfg = _cfg(gap=True, jump=True)
116+
117+
psess = run(_session(xy, z), cfg)
118+
119+
assert psess.jump_replaced is not None and psess.jump_replaced[30, 0]
120+
assert psess.z is not None
121+
np.testing.assert_allclose(psess.z[30, 0], 0.5 * (z[29] + z[31]))
122+
123+
124+
def test_jump_replaced_z_stays_missing_when_gap_fill_is_off() -> None:
125+
xy, z = _line()
126+
xy[30, 0, 0] += 500.0
127+
psess = run(_session(xy, z), _cfg(jump=True))
128+
assert psess.z is not None
129+
assert np.isnan(psess.z[30, 0])
130+
131+
132+
def test_z_is_bridged_across_a_tracking_interval_gap_like_xy() -> None:
133+
n = 20
134+
xy = np.zeros((n, 1, 2))
135+
xy[:, 0, 0] = np.arange(n, dtype=float)
136+
z = np.arange(n, dtype=float) + 100.0
137+
session = _session(xy, z, intervals=[(0, 10), (12, 22)])
138+
139+
psess = run(session, _cfg(gap=True, across=True), bridge_allowed=True)
140+
141+
assert psess.z is not None and psess.tracked_mask is not None
142+
assert psess.separator_mask is not None
143+
bridged = ~psess.tracked_mask & ~psess.separator_mask
144+
assert bridged.any()
145+
# anchors: z=109 at video frame 9, z=110 at frame 12; video frames 10 and 11 lie on the line
146+
np.testing.assert_allclose(psess.z[bridged, 0], [109 + 1 / 3, 109 + 2 / 3])
147+
148+
149+
def test_separator_rows_stay_missing_in_z() -> None:
150+
xy, z = _line(22)
151+
session = _session(xy, z, intervals=[(0, 10), (1000, 1012)])
152+
psess = run(session, _cfg(gap=True, smooth=SmoothCfg(enabled=True)))
153+
assert psess.separator_mask is not None and psess.separator_mask.any()
154+
assert psess.z is not None
155+
assert np.isnan(psess.z[psess.separator_mask]).all()
156+
157+
158+
def _noisy_helix(n: int = 600, sigma: float = 0.2):
159+
"""p7 of the 3-D review: a helix with 2 mm (0.2 cm) noise on every axis, 30 fps."""
160+
rng = np.random.default_rng(3)
161+
t = np.arange(n) / 30.0
162+
xy = np.zeros((n, 1, 2))
163+
xy[:, 0, 0] = 10 * np.cos(2 * np.pi * 0.2 * t) + rng.normal(0, sigma, n)
164+
xy[:, 0, 1] = 10 * np.sin(2 * np.pi * 0.2 * t) + rng.normal(0, sigma, n)
165+
true_z = 5 + 2 * np.sin(2 * np.pi * 0.1 * t)
166+
return xy, true_z + rng.normal(0, sigma, n), true_z
167+
168+
169+
def test_vertical_path_falls_with_the_smoothing_window_toward_the_truth() -> None:
170+
"""Before: the vertical path was 129 cm at every window against a true 16 cm."""
171+
xy, z, true_z = _noisy_helix()
172+
paths = []
173+
for window in (5, 15, 41):
174+
cfg = _cfg(smooth=SmoothCfg(enabled=True, method="savgol", window=window, polyorder=2))
175+
psess = run(_session(xy, z), cfg)
176+
assert psess.z is not None
177+
paths.append(_path(psess.z[:, 0]))
178+
179+
assert paths[0] > paths[1] > paths[2]
180+
assert paths[2] < 0.25 * _path(z)
181+
assert paths[2] < 1.5 * _path(true_z)
182+
183+
184+
def test_a_sensitivity_grid_point_changes_z() -> None:
185+
from track2data.core.models import PreprocessConfig as Cfg
186+
from track2data.sensitivity import GridPoint, _config_for
187+
188+
xy, z, _ = _noisy_helix()
189+
session = _session(xy, z)
190+
narrow = run(session, _config_for(Cfg(), GridPoint(smoothing_window=5, max_gap_frames=10)))
191+
wide = run(session, _config_for(Cfg(), GridPoint(smoothing_window=41, max_gap_frames=10)))
192+
assert narrow.z is not None and wide.z is not None
193+
assert _path(wide.z[:, 0]) < _path(narrow.z[:, 0])

‎track2data/api.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@ def import_sessions(
822822
# ── preprocessed-session cache ─────────────────────────────────────────
823823

824824
#: Bump when PreprocessedSession's layout or preprocessing semantics change.
825-
_CACHE_SCHEMA = 6
825+
_CACHE_SCHEMA = 7
826826

827827
def _cache_key(self, ref: SessionRef) -> tuple[Any, str] | None:
828828
"""(store, key) for the session *ref* describes, or None when caching is

‎track2data/core/models.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -924,10 +924,10 @@ class PreprocessedSession:
924924
depth_outside_mask: np.ndarray | None = None
925925
# (n_rows, n_animals) absolute vertical coordinate of a native 3-D session, in the
926926
# session's coordinate unit, laid out on the same rows as ``xy`` (NaN on inserted rows).
927-
# None for every other session. It is the file's raw coordinate: identity-switch correction,
928-
# jump replacement, gap fill and smoothing change ``xy`` but never touch ``z``, so it is
929-
# only valid for single-animal sources (native files are one animal) and 3-D speeds mix
930-
# smoothed xy with unsmoothed z.
927+
# None for every other session. It is the file's coordinate cleaned like ``xy``: blanked
928+
# where a jump was replaced, then bridged, gap-filled and smoothed with the same settings
929+
# (preprocess/z_axis.py). Identity-switch correction never touches it, so it is only valid
930+
# for single-animal sources (native files are one animal).
931931
z: np.ndarray | None = None
932932
# (n_animals,) int: the top session's animal index of each fused animal (fusion keeps only
933933
# the fish in the pair's fish map, so fused position k is not top animal k). None otherwise.

‎track2data/metrics/individual.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1884,7 +1884,8 @@ class PathLength3D(Metric):
18841884
warnings=[
18851885
"Under-smoothed data inflates the length, exactly as for IL-1: summing step "
18861886
"lengths sums |dx| rather than dx, so positional noise always adds length and the "
1887-
"inflation grows with frame rate. Depth noise adds to it as well.",
1887+
"inflation grows with frame rate. Depth noise adds to it as well; a native 3-D "
1888+
"file's z is smoothed with the same settings as x and y.",
18881889
"No refraction or parallax correction: a fish near the front glass looks larger "
18891890
"and sits at a different apparent depth than one at the back.",
18901891
"A wrong tank height scales every vertical distance, and so the 3-D length, by the "

‎track2data/preprocess/pipeline.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from track2data.preprocess.smoothing import smooth_trajectories
2121
from track2data.preprocess.timeline_expand import bridge_gaps, lay_out, plan_expansion
2222
from track2data.preprocess.validate import validate_coverage
23+
from track2data.preprocess.z_axis import clean_z
2324

2425

2526
def _replaced_mask(before: np.ndarray, after: np.ndarray) -> np.ndarray:
@@ -169,6 +170,9 @@ def _checkpoint(_step: str) -> None:
169170
_checkpoint("smoothing")
170171
xy, step = smooth_trajectories(xy, config.smoothing)
171172
report.steps.append(step)
173+
if z_rows is not None:
174+
# the vertical axis follows the horizontal one through the same gap-fill and smoothing
175+
z_rows = clean_z(z_rows, config.gap_fill, config.smoothing, expansion)
172176

173177
# 5. Coverage validation
174178
_checkpoint("coverage validation")

‎track2data/preprocess/z_axis.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""The vertical axis of a native 3-D file, put through the same cleaning steps as xy.
2+
3+
``z`` is a single column per animal, so it reuses the one-dimensional workers of the xy steps
4+
(``gap_fill._fill_series``, ``smoothing._smooth_series_*``, ``timeline_expand.bridge_gaps``) rather
5+
than the ``(n_frames, n_animals, 2)`` wrappers. The same settings govern it, so a 3-D position is
6+
cleaned as one object: smoothing the horizontal plane while leaving the vertical raw made the
7+
vertical share of a 3-D path length several times too large and no setting could reduce it.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import numpy as np
13+
14+
from track2data.core.models import GapFillCfg, SmoothCfg
15+
from track2data.preprocess.gap_fill import _fill_series
16+
from track2data.preprocess.smoothing import _smooth_series_moving_avg, _smooth_series_savgol
17+
from track2data.preprocess.timeline_expand import Expansion, bridge_gaps
18+
19+
20+
def clean_z(
21+
z: np.ndarray,
22+
gap_cfg: GapFillCfg,
23+
smooth_cfg: SmoothCfg,
24+
expansion: Expansion | None,
25+
) -> np.ndarray:
26+
"""``z`` ``(n_rows, n_animals)`` bridged, gap-filled and smoothed like xy; input untouched.
27+
28+
The caller blanks the rows where xy was jump-replaced first, so they are re-filled from their
29+
neighbours instead of keeping a raw vertical value beside a corrected horizontal one.
30+
"""
31+
out = z.copy()
32+
if expansion is not None and expansion.bridged_mask.any():
33+
out, _ = bridge_gaps(out[:, :, None], expansion)
34+
out = out[:, :, 0]
35+
if gap_cfg.enabled:
36+
protected = None if expansion is None else ~expansion.tracked_mask
37+
for k in range(out.shape[1]):
38+
out[:, k], _ = _fill_series(out[:, k], gap_cfg.max_gap_frames, protected)
39+
if smooth_cfg.enabled and smooth_cfg.method != "none":
40+
for k in range(out.shape[1]):
41+
if smooth_cfg.method == "savgol":
42+
out[:, k] = _smooth_series_savgol(
43+
out[:, k], smooth_cfg.window, smooth_cfg.polyorder
44+
)
45+
else:
46+
out[:, k] = _smooth_series_moving_avg(out[:, k], smooth_cfg.window)
47+
return out

0 commit comments

Comments
 (0)