diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2155c22..7613160f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### Added
+
+- **Three more 3-D metrics.** IL-18 *Vertical Speed* (mean, median and maximum of |dz/dt| in cm/s, plus body lengths per second), GL-18 *3-D Inter-Individual Distance* (mean over all pairs, the 3-D counterpart of GL-2) and GL-19 *3-D Group Cohesion* (reciprocal of the 3-D nearest-neighbour or inter-individual distance, chosen by `cohesion_source`). They need a fused or native 3-D session and a depth scale, skip frames with a missing animal as GL-16 does, and are greyed out otherwise. The registry now holds 57 metrics (decision D-052).
+- **Warning before a run when most animal slots are empty.** A multi-animal session in which at least half the (frame, animal) slots have no position, typically a Ctrax file with one slot per track fragment, now raises an advisory in `track2data validate`, the log and the run README, saying that group metrics skip any frame with a missing animal and pointing to the `top_n` option.
+- **Metadata: Excel workbooks with several sheets.** The metadata screen loads `.xlsx` files (it only took CSV before) and, when the workbook has more than one sheet, shows a **Sheet** menu; the choice is saved with the project. Projects without a chosen sheet are unchanged, including their project hash. The old `.xls` format is refused with a message that says to save as `.xlsx` or CSV.
+- **DeepLabCut `.h5` prediction files** are read (`deeplabcut_h5`). The numbers and the pickled column labels are decoded without PyTables, and the labels by an unpickler that refuses every global, so a crafted file cannot run code. A `.csv` and `.h5` pair of the same name is one session. The reader is marked synthetic-only until the real sample has been read in a run; the multi-animal `*_el.h5` tracklet files are refused with a remediation.
+- **`movement` / xarray adapter.** `track2data.interop.movement.to_movement_dataset(psess)` returns a preprocessed session as an `xarray.Dataset` in the `movement` layout (`position` over time, individuals, keypoints, space), with true video time across tracking gaps, a `z` coordinate for native 3-D sessions, and the tracker's identity probability as a separate `identity_confidence` variable. Needs the new optional `xarray` extra.
+- **Import: "Choose software…" when nothing was recognised.** The empty-state dialog lists the readers that can try again with looser rules, and `track2data add --reader NAME` does the same on its own. DeepLabCut accepts a prediction table saved as `.txt` or `.dat`, and SLEAP an analysis file without an `.h5` extension. The header must still match, a candidate still goes through the reader's own `read`, and it is offered at low confidence.
+- **Import: "Read from video…"** fills the frame rate and frame size for readers that need them (DeepLabCut, SLEAP) from a video you choose (needs the optional `av` package). It never overwrites a value you typed, and an unreadable video changes nothing.
+- **Zones: the video's first frame is the backdrop** when a session has no background image (and no panel). It is used only when the decoded frame is exactly the declared video size, so zones are never drawn in the wrong pixel space; `extract_frame_rgb` returns the frame with its decoded size.
+
+### Changed
+
+- **IL-3 and IL-14 are skipped on a declared side view.** The arena centre and the wall distance are plan-view quantities, so a project whose camera view is **Side view** now greys them out and skips them (decision D-045). A project that never set a view, or set Top-down, is unchanged, as are fused and native 3-D sessions.
+
+### Fixed
+
+- **idtracker.ai v5 sessions with no recorded frame rate can be given one.** The legacy v5 reader now takes an `fps` option (`--option fps=25`, or the Frame rate field on import), used only after `video_object.npy` and the session json have been tried. Without any of the three it still stops with `VIDEO_FPS_MISSING` rather than assuming 1 fps; the message now says how to supply it.
+- **Rescanning a folder that was split into panels** offers the whole-frame session again instead of reporting it "already in the project". The import dialog and the project store now share one duplicate rule (decision D-046); a true duplicate is still left out.
+- **idtracker.ai pickle reader could run code.** The allowlist for `list_of_blobs.pickle` admitted every class in any module whose name starts with `numpy`, including helpers that execute code. It is now an exact list of the array-reconstruction functions numpy pickles use.
+- **Z-5 no longer invents an exit at a tracking-interval gap.** A stay in a zone that continued across two tracking intervals was reported as enter, exit, enter, with the exit on the separator row between them. Entry/exit counts, Z-6 and Z-9 were inflated for sessions tracked in separate intervals.
+- **Cache key includes the identity-free override.** Changing a session's identity-free setting now invalidates its cached preprocessing instead of serving the old gap-bridging.
+- **Binned exports.** The long table keeps `bin_index`, `bin_start_s` and `bin_end_s` as identifiers rather than melting them into measurements, and merging a binned metric after a whole-session one no longer drops its bin boundaries.
+- **idtracker.ai v5 reader** raises `VIDEO_FPS_MISSING` when the frame rate is in neither `video_object.npy` nor the session json, instead of assuming 1 fps. A real 1 fps recording is no longer mistaken for "missing".
+- **DeepLabCut multi-animal files with positional names** (`ind1`, `ind2`, ...) are declared identity-free, as SLEAP files already were, so per-individual metrics are skipped for them.
+- **Calibration page** offers inches and keeps whatever unit label the project has instead of rewriting it to the first choice on Apply.
+- **`track2data run --workers N`** now exists; the Processing page's headless command used it before the option did, and adds `--cache-dir` when a cache is in use.
+- **Export page** asks before overwriting after each export, not only when the folder is first chosen; the default folder moves on to a fresh timestamp.
+- **Metadata join** matches numeric session or key columns (trial numbers) against folder names.
+- **Panels:** a panel that no animal is in is refused with a message instead of producing a session of zero animals.
+- **Zones CSV** keeps each ROI's `+`/`-` sign, same-named polygons and the source resolution through save and load (older four-column files still load); `flip_orientation` keeps them too.
+- **Removing a session** drops its located-video override, so a later session with the same name does not inherit it.
+
## [0.3.0] — 2026-10-13
### Added
diff --git a/README.md b/README.md
index 5d204b5d..119451dd 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
You tracked your animals. idtracker.ai gave you a folder of trajectories.
Track2Data turns that into tables you can put straight into R or Python:
-54 behavioural metrics, every one with a citation, in documented units,
+57 behavioural metrics, every one with a citation, in documented units,
with a record of exactly which frames were measured and which were
reconstructed.
@@ -209,7 +209,7 @@ every export's `manifest.json`.
A rendered site is published from these files at
****, including a browsable
-catalogue of all 54 metrics with their formulas, units and DOIs.
+catalogue of all 57 metrics with their formulas, units and DOIs.
The **[wiki](https://github.com/mbel-tech/Track2Data/wiki)** covers what sits
around that reference material and is not pinned to a release:
diff --git a/docs/3d-movement/2026-10-08-3d-roadmap.md b/docs/3d-movement/2026-10-08-3d-roadmap.md
index 2d214fe4..abf8cddb 100644
--- a/docs/3d-movement/2026-10-08-3d-roadmap.md
+++ b/docs/3d-movement/2026-10-08-3d-roadmap.md
@@ -28,5 +28,5 @@ project-level camera-view setting that B reuses.
a vertical axis for 3-D data.
- The `has_pixel_frame` guard from native-units joined the IL-3 and IL-14 arena fallback in A (the data extent is used, with a warning).
- Per-session camera views arrive with D (`Engine.camera_view_for(session)` is the seam).
-- Whether IL-3 and IL-14, which assume a top-down view, should be gated for side views is
- left open.
+- IL-3 and IL-14, which assume a top-down view, are gated for side views (D-045); an unset view keeps
+ both.
diff --git a/docs/3d-movement/2026-10-08-side-view-depth-design.md b/docs/3d-movement/2026-10-08-side-view-depth-design.md
index d0223860..61dda8c5 100644
--- a/docs/3d-movement/2026-10-08-side-view-depth-design.md
+++ b/docs/3d-movement/2026-10-08-side-view-depth-design.md
@@ -15,7 +15,8 @@ computed from a top-down y axis.
- The metric is **IL-15**, not Z-10: exports bucket metrics by ID prefix and the UI by level.
Z-10 is re-reserved for auto depth bands.
- Camera view is project-level. A per-session override arrives with sub-project D.
-- Existing metrics are not gated by camera view in this cycle.
+- Existing metrics were not gated by camera view in this cycle. (IL-3 and IL-14 are gated to top-down and
+ unset views since D-045.)
## Design
diff --git a/docs/ENGINE_DESIGN.md b/docs/ENGINE_DESIGN.md
index cdffb652..625e7c36 100644
--- a/docs/ENGINE_DESIGN.md
+++ b/docs/ENGINE_DESIGN.md
@@ -385,10 +385,14 @@ ALIASES = {"condition": "treatment", "date": "trial_date"}
### 6.2 Loader
-`metadata.loader.load(path)` returns a `pandas.DataFrame` with stripped
-whitespace, lowered string columns where appropriate, and ISO-8601
-dates. Excel files: read sheet 0; multi-sheet support is a future
-feature.
+`metadata.loader.load(path, sheet=None)` returns a `pandas.DataFrame` with
+stripped whitespace, lowered string columns where appropriate, and ISO-8601
+dates. For an Excel workbook `sheet` names the sheet to read; `None` reads the
+first. `list_sheets(path)` gives a workbook's sheet names (`[]` for a CSV) and
+an unknown name raises `ValueError` listing them. The chosen sheet is stored
+in `MetadataSource.sheet`, which is left out of the saved manifest while it is
+`None` so projects without a sheet keep their `project_hash`. The old `.xls`
+format is refused with a message (openpyxl cannot read it).
### 6.3 Mapping & join
diff --git a/docs/INTEROPERABILITY.md b/docs/INTEROPERABILITY.md
index 376bd786..14ba077a 100644
--- a/docs/INTEROPERABILITY.md
+++ b/docs/INTEROPERABILITY.md
@@ -50,93 +50,68 @@ long.merge(book[["column", "unit", "doi"]].drop_duplicates(), on="column")
trajectory libraries expect: one row per (frame, individual) with `x_px`,
`y_px`, `time_s`, and reconstruction flags.
-## Planned: the `movement` package
+## Using the `movement` package
[`movement`](https://movement.neuroinformatics.dev/) (Neuroinformatics Unit,
-UCL) is an xarray-based library for animal-movement data. It is a natural
-downstream consumer: it has plotting, filtering and kinematic utilities that
-Track2Data does not attempt, and it reads pose data from DeepLabCut, SLEAP
-and others.
+UCL) is an xarray-based library for animal-movement data, with plotting,
+filtering and kinematic utilities that Track2Data does not attempt. Track2Data
+hands it a preprocessed session as an `xarray.Dataset` in `movement`'s layout.
+Install the extra first: `pip install track2data[xarray]`.
-**Not implemented yet.** The mapping is documented here first so that the
-shape of the adapter is agreed before it is written, and so anyone who wants
-it in the meantime can do the conversion by hand.
+```python
+from track2data.api import Engine
+from track2data.core.manifest import read
+from track2data.interop.movement import to_movement_dataset
+
+engine = Engine(read("project.t2d.json"))
+psess = engine.preprocess(engine.import_session("session_folder"))
+ds = to_movement_dataset(psess)
+```
### The mapping
-`movement` centres on a `xarray.Dataset` with a `position` DataArray of
-dimensions `(time, individuals, keypoints, space)`.
+`position` has dimensions `(time, individuals, keypoints, space)`.
| `movement` | Track2Data | Note |
|---|---|---|
-| `position` | `PreprocessedSession.xy`, `(n_frames, n_animals, 2)` | Add a length-1 `keypoints` axis |
-| `time` | `frame / fps` | `time_s` in the per-frame table |
-| `individuals` | `Session.identities_labels`, else `0..n-1` | Labels come from the Validator when set |
-| `keypoints` | a single `"centroid"` | idtracker.ai tracks a centroid, not a skeleton |
-| `space` | `["x", "y"]` | Always 2-D |
-| `confidence` | `Session.id_probabilities` | Identity confidence, **not** pose confidence — see below |
-| `fps` (attr) | `Session.video.fps` | |
-| `source_software` (attr) | `"idtracker.ai"` | |
+| `position` | `PreprocessedSession.xy` (plus `z` for a native 3-D session) | A length-1 `keypoints` axis is added |
+| `time` | the session's timeline divided by `fps` | True video time, so a session tracked in separate intervals keeps its gaps |
+| `individuals` | `Session.identities_labels`, else `0..n-1` | |
+| `keypoints` | the keypoint that stands for the animal, else `"centroid"` | The rest of a skeleton is not exported |
+| `space` | `["x", "y"]`, or `["x", "y", "z"]` for a native 3-D session | |
+| `identity_confidence` | `Session.id_probabilities` | A separate variable, **not** `confidence` (see below); absent when the tracker gave none |
+| attrs | `fps`, `source_software`, `reader_verification`, `length_unit`, `px_per_cm` | `length_unit` is the unit of `position` (`px`, or the file's unit for a native 3-D session) |
+
+The single NaN row that stands for an unfilled gap between tracking intervals
+is dropped; pass `include_separator_rows=True` to keep it. Rows for unobserved
+video stay as NaN.
### Two things that do not map cleanly
**`confidence` means something different.** In `movement` it is a pose
estimator's per-keypoint confidence: how sure the network is about *where*
the body part is. idtracker.ai's `id_probabilities` is how sure it is about
-*which animal* this is — the position may be exact while the identity is a
-coin flip. Writing one into the other's slot would silently invite a filter
-on the wrong quantity. Any adapter should carry it as a separately-named
-variable, or document the substitution loudly.
+*which animal* this is, so the position may be exact while the identity is a
+coin flip. Writing one into the other's slot would invite a filter on the wrong
+quantity, which is why it is named `identity_confidence`.
**Zones and calibration have no home in the pose schema.** `main_zone`,
-`sec_zone`, `px_per_cm` and the body-length scale are Track2Data concepts.
-They would ride along as dataset attributes and extra data variables, which
-`movement` tolerates but does not interpret.
-
-### If you need it now
-
-```python
-import numpy as np
-import xarray as xr
-from track2data.api import Engine
-from track2data.core.manifest import read
-
-engine = Engine(read("project.t2d.json"))
-psess = engine.preprocess(engine.import_session("session_folder"))
-
-n_frames, n_animals, _ = psess.xy.shape
-labels = psess.session.identities_labels or [str(k) for k in range(n_animals)]
-
-ds = xr.Dataset(
- {
- "position": (
- ("time", "individuals", "keypoints", "space"),
- psess.xy[:, :, np.newaxis, :],
- )
- },
- coords={
- "time": np.arange(n_frames) / psess.fps,
- "individuals": labels,
- "keypoints": ["centroid"],
- "space": ["x", "y"],
- },
- attrs={"fps": psess.fps, "source_software": "idtracker.ai"},
-)
-```
-
-## Other formats
+`sec_zone` and the body-length scale stay in Track2Data; only `px_per_cm`
+rides along as an attribute.
| Target | Status | Notes |
|---|---|---|
| CSV (long and wide) | ✅ shipped | `csv_long`, `csv_wide` |
| Excel | ✅ shipped | `excel` exporter, needs `openpyxl` |
| Feather / Arrow | ✅ shipped | `feather` exporter, needs `pyarrow` |
-| `movement` xarray | 📋 documented above | Not implemented |
+| `movement` xarray | ✅ shipped | `track2data.interop.movement.to_movement_dataset`, needs `xarray` |
| DeepLabCut CSV input (also Lightning Pose, EKS) | ✅ supported | One session per prediction file; the frame rate and frame size are options you give (the file records neither); one keypoint stands for the animal and the whole skeleton is stored beside it. See `docs/tracker-formats/README.md`. |
| SLEAP analysis HDF5 input | ✅ supported | In SLEAP choose *File > Export Analysis HDF5*. One session per file; the frame rate and frame size are options you give; one skeleton node stands for the animal and the whole skeleton is stored beside it. `.slp` project files are not read. See `docs/tracker-formats/README.md`. |
| Ctrax raw `.mat` input | ✅ supported | The file Ctrax's *Save Tracks as Matlab File* writes. Frame size is an option you give; the frame rate comes from the file. Ctrax track ids are fragments, so a session is identity-free and one slot per id (`top_n` keeps the longest). See `docs/tracker-formats/README.md`. |
| `trx.mat` input (Ctrax / FlyTracker / JAABA) | 🧪 synthetic only | A classic (v5/v7) MATLAB file holding a `trx` struct, built from the documented layout with no real sample pinned yet. Frame size is an option; `fps` is read from the file. `pxpermm` (pixels per mm) becomes the session scale for Session calibration, pending the usual unit confirmation; a value of exactly 1 is ignored. v7.3 files are not read. |
-| DeepLabCut `.h5` input | 🕒 planned | Waiting on the decoder decision in the reader rollout (`docs/tracker-formats/`). Until then export DeepLabCut as CSV. A reader plug-in is also possible — see the `track2data.readers` entry point in `CONTRIBUTING.md`. |
+| DeepLabCut `.h5` input | 🧪 synthetic only | The `.h5` DeepLabCut writes beside the CSV (pandas table under `df_with_missing`). Read with h5py; the pickled column labels are decoded by an unpickler that refuses every global, and PyTables is not needed (D-050). The frame rate and frame size are options you give; the keypoint is typed, not picked from a list. A CSV of the same name beside it is the one session. Multi-animal tracklet files (`*_el.h5`) are refused. Becomes *supported* once the real EPM sample has been read in a run. |
+| Anipose pose-3d CSV input | 🧪 synthetic only | One animal per file, native 3-D (X, Y, Z in the file's unit). Built from the documented layout; no real sample pinned yet. Several animals in one file are not read. |
+| DeepLabCut-3D input | 🧪 synthetic only | The triangulated 3-D table DeepLabCut writes, read as native 3-D. Built from the documented layout; no real sample pinned yet. |
| NWB | ❌ not planned | Would be worth revisiting if the behavioural-NWB extensions stabilise. |
Writing a reader for another tracker does not require changing Track2Data:
diff --git a/docs/METRICS_SPEC.md b/docs/METRICS_SPEC.md
index 4fb99f5f..7428f183 100644
--- a/docs/METRICS_SPEC.md
+++ b/docs/METRICS_SPEC.md
@@ -129,11 +129,11 @@ Individual Locomotion IL-1, IL-2, IL-6
Activity / freezing IL-4, IL-7
Space use IL-3, IL-9, IL-10, IL-14
Vertical position IL-15
- 3-D movement IL-16, IL-17
+ 3-D movement IL-16, IL-17, IL-18
Path geometry IL-5, IL-8, IL-11
Group Social spacing GL-1, GL-2, GL-13
Cohesion GL-4, GL-6, GL-10, GL-15
- 3-D spacing GL-16
+ 3-D spacing GL-16, GL-18, GL-19
Collective motion GL-3, GL-5, GL-8, GL-9, GL-11
Identity-free fallback GL-7
Zone Occupancy Z-1, Z-2, Z-8
@@ -212,7 +212,7 @@ info-button modal (§6).
| **Assumptions** | Arena is roughly circular or a centre point is meaningful |
| **Warnings** | For non-circular arenas, "centre-distance" is interpretable only with a clearly defined origin. In a non-circular (e.g. rectangular) arena, centre-distance is not monotonic in distance-to-wall — a corner-hugging animal can score the same centre-distance as one near a wall's midpoint; see **IL-14** for the wall-distance-specific measure. |
| **Parameters** | `inner_radius_fraction` (float, default 0.5). `centre` / `arena_radius` / `centres` / `arena_radii` are `derived=True` and cannot be overridden. |
-| **Availability** | Not offered for native 3-D projects (Anipose, DeepLabCut 3-D files): "uses a pixel-sized default and the project's native 3-D files have no pixel frame". Its default is sized in video pixels, which would silently change with the project's length unit. Pixel-frame and fused 3-D projects are unchanged. |
+| **Availability** | Camera view top-down or not set; greyed out and skipped on a declared side view (D-045), because the arena centre is a plan-view quantity. Not offered for native 3-D projects (Anipose, DeepLabCut 3-D files): "uses a pixel-sized default and the project's native 3-D files have no pixel frame". Its default is sized in video pixels, which would silently change with the project's length unit. Pixel-frame and fused 3-D projects are unchanged. |
> **Where the centre comes from.** It is derived in
> `track2data/metrics/derived.py`, not supplied by the user — a value
@@ -395,7 +395,7 @@ info-button modal (§6).
| **Parameters** | `arena_polygon_vertices` / `arena_polygon_vertices_per_animal` are `derived=True` and cannot be overridden. `wall_contact_threshold_px` (float, px, default 20.0). |
| **Reference** | Simon et al. 1994, Behav. Brain Res. 61(1):59-64 (thigmotaxis as an index of anxiety in mice) — DOI [10.1016/0166-4328(94)90008-6](https://doi.org/10.1016/0166-4328(94)90008-6) |
| **Supporting references** | Schnorr et al. 2012, Behav. Brain Res. 228(2):367-374 (thigmotaxis in larval zebrafish) (DOI: 10.1016/j.bbr.2011.12.016); Maximino et al. 2010, Behav. Brain Res. 214(2):157-171 (measuring anxiety in zebrafish: a critical review) (DOI: 10.1016/j.bbr.2010.05.031) |
-| **Availability** | Not offered for native 3-D projects (Anipose, DeepLabCut 3-D files): "uses a pixel-sized default and the project's native 3-D files have no pixel frame". Its default is sized in video pixels, which would silently change with the project's length unit. Pixel-frame and fused 3-D projects are unchanged. |
+| **Availability** | Camera view top-down or not set; greyed out and skipped on a declared side view (D-045), because the distance to the arena boundary is a plan-view quantity. Not offered for native 3-D projects (Anipose, DeepLabCut 3-D files): "uses a pixel-sized default and the project's native 3-D files have no pixel frame". Its default is sized in video pixels, which would silently change with the project's length unit. Pixel-frame and fused 3-D projects are unchanged. |
#### IL-15 — Vertical position (depth)
@@ -449,6 +449,23 @@ info-button modal (§6).
| **Reference** | Standard kinematics |
| **Supporting references** | Bjorneraas et al. 2010, J. Wildl. Manage. 74(6):1361-1366 (screening GPS location data for errors using animal movement characteristics) (DOI: 10.2193/2009-405) |
+#### IL-18 — Vertical speed (mean / median / max)
+
+| Field | Value |
+|---|---|
+| **Manuscript label** | Vertical speed |
+| **Level** | Individual; trial summary |
+| **Priority** | Optional |
+| **Inputs** | `PreprocessedSession.depth` and `depth_height_cm` of a fused 3-D session (for a native 3-D file, the absolute `PreprocessedSession.z` in place of `depth · tank_height_cm`), `px_per_cm`; the pipeline's `KinematicsCfg` |
+| **Required preprocessing** | A fused 3-D session (top view plus side view) with a cm scale for the top view (scalar or session calibration, not body-length calibration). A project without these skips the metric and the run records why |
+| **Formula** | `|vz[t, k]|`, the vertical speed in cm/s of `Z = depth · tank_height_cm` (for a native file, its z after the same gap filling and smoothing as x and y, with samples outside the water column left out), from the same estimator and window as the pipeline's kinematics; mean/median/max over frames where it is finite. This is the vertical part of IL-17 on its own |
+| **Output columns** | `individual_id`, `mean_vertical_speed_cm_s`, `median_vertical_speed_cm_s`, `max_vertical_speed_cm_s`, `mean_vertical_speed_bl_s` |
+| **Units** | cm/s / BL/s |
+| **Assumptions** | The speed is unsigned, so a climb and a dive count the same; a signed or climb/dive split measure is not offered. Where the estimator gives no vertical speed (the last frame of the forward-difference estimator, a depth segment shorter than the window needs) the frame is left out, never counted as 0. An animal with no such frame is NaN. As for IL-17, `vz` is estimated once on the whole session's Z and sliced with the session |
+| **Warnings** | Max speed is sensitive to depth noise and jump artefacts. No refraction or parallax correction. A wrong tank height scales every vertical speed. Without depth or a cm scale all columns are NaN |
+| **Reference** | Standard kinematics |
+| **Supporting references** | Bjorneraas et al. 2010, J. Wildl. Manage. 74(6):1361-1366 (screening GPS location data for errors using animal movement characteristics) (DOI: 10.2193/2009-405) |
+
### 4.2 Zone metrics
#### Z-1 — Time in each zone
@@ -850,6 +867,41 @@ Group metrics (`GL-*`) are unavailable in native 3-D projects: see the Availabil
| **Reference** | Clark & Evans 1954, Ecology 35(4):445-453 (distance to nearest neighbor as a measure of spatial relationships in populations) — DOI [10.2307/1931034](https://doi.org/10.2307/1931034) |
| **Supporting references** | Pitcher 1973, Anim. Behav. 21(4):673-686 (the three-dimensional structure of schools in the minnow, Phoxinus phoxinus) (DOI: 10.1016/S0003-3472(73)80091-0); Krause & Ruxton 2002, Living in Groups (Oxford University Press) (DOI: 10.1093/oso/9780198508175.001.0001) |
+#### GL-18 — 3-D inter-individual distance
+
+| Field | Value |
+|---|---|
+| **Manuscript label** | Inter-individual distance in 3-D |
+| **Level** | Group (per frame averaged across pairs); trial summary |
+| **Priority** | Optional |
+| **Inputs** | `PreprocessedSession.xy`, `PreprocessedSession.depth`, `px_per_cm` and `depth_height_cm` of a fused 3-D session |
+| **Required preprocessing** | A fused 3-D session (top view plus side view) with a cm scale for the top view (scalar or session calibration, not body-length calibration). A project without these skips the metric and the run records why |
+| **Formula** | `P[t, k] = (x/px_per_cm, y/px_per_cm, depth · tank_height_cm)`; `iid[t] = mean(pdist(P[t]))`, the mean of all pairwise Euclidean distances; mean and median over frames |
+| **Output columns** | `mean_iid_3d_cm`, `median_iid_3d_cm`, `mean_iid_3d_bl`, `n_skipped_frames_3d`, `frac_frames_used_3d` |
+| **Units** | cm / BL / frames / fraction (0-1) |
+| **Assumptions** | The depth comes from a side camera at right angles to the top camera. A frame needs finite X, Y and Z for every animal; other frames are skipped and counted in `n_skipped_frames_3d`, and `frac_frames_used_3d` is the share of the real frames (separator rows excluded) that were used. Needs no identities (an unordered point set per frame, like GL-2). `mean_iid_3d_bl` uses GL-16's body-length rule. One animal, or no valid frame, gives NaN (never 0) |
+| **Warnings** | GL-2 and GL-18 are computed on different frames whenever depth is missing, so `mean_iid_px` and `mean_iid_3d_cm` are not a like-for-like comparison. No refraction or parallax correction. A wrong tank height scales every vertical distance. Without depth or a cm scale all statistics are NaN |
+| **Reference** | Krause & Ruxton 2002, Living in Groups (Oxford University Press) — DOI [10.1093/oso/9780198508175.001.0001](https://doi.org/10.1093/oso/9780198508175.001.0001) |
+| **Supporting references** | Miller & Gerlai 2007, Behav. Brain Res. 184(2):157-166 (quantification of shoaling behaviour in zebrafish) (DOI: 10.1016/j.bbr.2007.07.007); Delcourt & Poncin 2012, Rev. Fish Biol. Fish. 22(3):595-619 (shoals and schools: back to the heuristic definitions and quantitative references) (DOI: 10.1007/s11160-012-9260-z) |
+
+#### GL-19 — 3-D group cohesion index
+
+| Field | Value |
+|---|---|
+| **Manuscript label** | Group cohesion in 3-D |
+| **Level** | Group (trial summary) |
+| **Priority** | Optional |
+| **Inputs** | The 3-D distances of GL-16 (`cohesion_source='nnd'`, default) or GL-18 (`'iid'`), from the `PreprocessedSession.xy`, `depth`, `px_per_cm` and `depth_height_cm` of a fused 3-D session |
+| **Required preprocessing** | As GL-18 |
+| **Formula** | `cohesion_index_3d = 1 / mean_nnd_3d_cm` or `1 / mean_iid_3d_cm`, the mean over the frames with a full 3-D position for every animal |
+| **Parameters** | `cohesion_source` (the choices are nnd and iid; any other value is refused) |
+| **Output columns** | `cohesion_index_3d` |
+| **Units** | 1/cm |
+| **Assumptions** | A frame needs finite X, Y and Z for every animal; needs no identities |
+| **Warnings** | The index is per cm, so it is not comparable with GL-6, which is per pixel (or BL⁻¹ with calibration). NaN when the mean distance is 0, with fewer than two animals, or without depth or a cm scale |
+| **Reference** | Krause & Ruxton 2002, Living in Groups (Oxford University Press) — DOI [10.1093/oso/9780198508175.001.0001](https://doi.org/10.1093/oso/9780198508175.001.0001) |
+| **Supporting references** | Delcourt & Poncin 2012, Rev. Fish Biol. Fish. 22(3):595-619 (shoals and schools: back to the heuristic definitions and quantitative references) (DOI: 10.1007/s11160-012-9260-z) |
+
### 4.5 Identity-free variants
Group metrics (`GL-*`) are unavailable in native 3-D projects: see the Availability note under 4.3.
@@ -890,6 +942,7 @@ three together.
| IL-15 Vertical Position (Depth) | ❌ | Per-animal time series |
| IL-16 3-D Distance Travelled | ❌ | Per-animal time series |
| IL-17 3-D Speed | ❌ | Per-animal time series |
+| IL-18 Vertical Speed | ❌ | Per-animal time series |
| GL-1 Nearest-Neighbour Distance | ✅ | Unordered point set per frame |
| GL-2 Inter-Individual Distance | ✅ | Unordered point set per frame |
| GL-3 Polarisation | ❌ | Heading requires per-individual tracklets |
@@ -904,6 +957,8 @@ three together.
| GL-13 Topological k-NN Counts | ✅ | Per-frame k-nearest-neighbour counts |
| GL-15 Group Elongation / Anisotropy | ✅ | Per-frame covariance of the point set |
| GL-16 3-D Nearest-Neighbour Distance | ✅ | Unordered 3-D point set per frame |
+| GL-18 3-D Inter-Individual Distance | ✅ | Unordered 3-D point set per frame |
+| GL-19 3-D Group Cohesion | ✅ | Built on GL-16/GL-18's unordered point sets |
| Z-1 Time in zone | ✅ | Pure occupancy; emitted **pooled** (no `individual_id`) on identity-free sessions \|
| Z-2 Area-Corrected Occupancy | ✅ | Pure occupancy; emitted **pooled** (no `individual_id`) on identity-free sessions \|
| Z-3 Zone visit count | ❌ | Follows one slot across frames (visits / events / sequences) \|
@@ -1209,6 +1264,8 @@ Every metric ID maps to a concrete class in `track2data/metrics/*.py`.
| GL-13 | `metrics/group.py` | `TopologicalNeighbourCounts` | `scipy.spatial.cKDTree`, same tree GL-1 builds |
| GL-15 | `metrics/group.py` | `GroupElongation` | `np.linalg.eigh` on the per-frame position covariance |
| GL-16 | `metrics/group.py` | `NearestNeighbourDistance3D` | `scipy.spatial.cKDTree` on the 3-D positions of `metrics/geometry3d.py`; gated by `Metric.requires_depth_scale` |
+| GL-18 | `metrics/group.py` | `InterIndividualDistance3D` | `scipy.spatial.distance.pdist` on the 3-D positions of `metrics/geometry3d.py`; gated by `Metric.requires_depth_scale` |
+| GL-19 | `metrics/group.py` | `GroupCohesion3D` | GL-16's `cKDTree` distance or GL-18's `pdist`, per `cohesion_source`; gated by `Metric.requires_depth_scale` |
| Z-1..Z-6 | `metrics/zone.py` | `TimeInZone`, `AreaCorrectedOccupancy`, `ZoneVisitCount`, `ZoneTransitions`, `Z5EntryExitEvents`, `Z6LatencyToFirstEntry` | `shapely` for point-in-polygon |
| Z-7 | `metrics/zone.py` | `ZoneTransitionMatrix` | Reuses Z-4's debounced zone sequence |
| Z-8 | `metrics/zone.py` | `ZonePreferenceIndex` | Reuses Z-2's `roi_areas`/`total_arena_area` derivation |
@@ -1221,6 +1278,7 @@ Every metric ID maps to a concrete class in `track2data/metrics/*.py`.
| IL-15 | `metrics/individual.py` | `VerticalPosition` | np-only; the water column comes from `zones/extent.py`, derived in `metrics/derived.py`; gated by `Metric.valid_camera_views` (`metrics/availability.py`) |
| IL-16 | `metrics/individual.py` | `PathLength3D` | np-only; positions from `metrics/geometry3d.py`; gated by `Metric.requires_depth_scale` (`metrics/availability.py`) |
| IL-17 | `metrics/individual.py` | `Speed3D` | np-only; positions from `metrics/geometry3d.py`; vertical speed from `compute_kinematics` with the pipeline's `KinematicsCfg` (handed over by the Engine through `Metric.uses_kinematics_cfg`); gated by `Metric.requires_depth_scale` |
+| IL-18 | `metrics/individual.py` | `VerticalSpeed3D` | np-only; the vertical part of IL-17 (`metrics/geometry3d.vertical_speed`, or the Engine's precomputed `z_speed`); gated by `Metric.requires_depth_scale` |
### 5.1 Shared computations (no duplicated work)
@@ -1451,7 +1509,7 @@ exposed so future per-user opt-outs are non-breaking.
flicker debounce. The GUI's ⚙ button now opens `MetricConfigDialog`
(`ui/dialogs/metric_config_dialog.py`) for any metric that declares
`parameters`, one widget per parameter keyed off `MetricParameter.kind`;
- it is disabled with an explanatory tooltip for the 18 of the 38
+ it is disabled with an explanatory tooltip for the 20 of the 41
metrics it lists that declare none (diagnostics always run and
aren't selectable there, so they don't count towards either
figure; both are pinned by
diff --git a/docs/METRIC_REFERENCES.csv b/docs/METRIC_REFERENCES.csv
index d4b01d5e..5953c479 100644
--- a/docs/METRIC_REFERENCES.csv
+++ b/docs/METRIC_REFERENCES.csv
@@ -29,6 +29,8 @@ GL-11,group,primary,true,Order-State Classification,"Tunstrom et al. 2013, PLoS
GL-13,group,optional,false,Topological k-NN Counts,"Ballerini et al. 2008, Proc. Natl. Acad. Sci. 105(4):1232-1237 (interaction ruling animal collective behavior depends on topological rather than metric distance)",10.1073/pnas.0711437105,"Clark & Evans 1954, Ecology 35(4):445-453 (distance to nearest neighbor as a measure of spatial relationships in populations) (DOI: 10.2307/1931034)"
GL-15,group,optional,false,Group Elongation / Anisotropy,"Tunstrom et al. 2013, PLoS Comput. Biol. 9(2):e1002915 (collective states, multistability and transitional behavior in schooling fish)",10.1371/journal.pcbi.1002915,"Mohr 1947, Am. Midl. Nat. 37(1):223-249 (table of equivalent populations of North American small mammals -- origin of the minimum convex polygon) (DOI: 10.2307/2421652)"
GL-16,group,optional,false,3-D Nearest-Neighbour Distance,"Clark & Evans 1954, Ecology 35(4):445-453 (distance to nearest neighbor as a measure of spatial relationships in populations)",10.2307/1931034,"Pitcher 1973, Anim. Behav. 21(4):673-686 (the three-dimensional structure of schools in the minnow, Phoxinus phoxinus) (DOI: 10.1016/S0003-3472(73)80091-0); Krause & Ruxton 2002, Living in Groups (Oxford University Press) (DOI: 10.1093/oso/9780198508175.001.0001)"
+GL-18,group,optional,false,3-D Inter-Individual Distance,"Krause & Ruxton 2002, Living in Groups (Oxford University Press)",10.1093/oso/9780198508175.001.0001,"Miller & Gerlai 2007, Behav. Brain Res. 184(2):157-166 (quantification of shoaling behaviour in zebrafish) (DOI: 10.1016/j.bbr.2007.07.007); Delcourt & Poncin 2012, Rev. Fish Biol. Fish. 22(3):595-619 (shoals and schools: back to the heuristic definitions and quantitative references) (DOI: 10.1007/s11160-012-9260-z)"
+GL-19,group,optional,false,3-D Group Cohesion,"Krause & Ruxton 2002, Living in Groups (Oxford University Press)",10.1093/oso/9780198508175.001.0001,"Delcourt & Poncin 2012, Rev. Fish Biol. Fish. 22(3):595-619 (shoals and schools: back to the heuristic definitions and quantitative references) (DOI: 10.1007/s11160-012-9260-z)"
IL-1,individual,primary,true,Distance Travelled,Standard kinematics,,"Martin & Bateson 2007, Measuring Behaviour: An Introductory Guide, 3rd ed. (Cambridge University Press) (DOI: 10.1017/CBO9780511810893)"
IL-2,individual,primary,true,Speed (mean / median / max),Standard kinematics,,"Bjorneraas et al. 2010, J. Wildl. Manage. 74(6):1361-1366 (screening GPS location data for errors using animal movement characteristics) (DOI: 10.2193/2009-405)"
IL-3,individual,primary,true,Distance from Arena Centre,"Schnorr et al. 2012, Behav. Brain Res. 228(2):367-374 (thigmotaxis in larval zebrafish)",10.1016/j.bbr.2011.12.016,"Simon et al. 1994, Behav. Brain Res. 61(1):59-64 (thigmotaxis as an index of anxiety in mice) (DOI: 10.1016/0166-4328(94)90008-6); Hall 1934, J. Comp. Psychol. 18(3):385-403 (emotional behavior in the rat: I. Defecation and urination as measures of individual differences in emotionality) (DOI: 10.1037/h0071444)"
@@ -44,6 +46,7 @@ IL-14,individual,primary,true,Wall-Distance Thigmotaxis,"Simon et al. 1994, Beha
IL-15,individual,optional,true,Vertical Position (Depth),"Standard descriptive statistic of vertical position in the water column; no single originating work defines this mean-depth fraction. The construct, vertical position as a behavioural measure in novel-tank assays, is in the supporting references.",,"Cachat et al. 2010, Nat. Protoc. 5(11):1786-1799 (measuring behavioral and endocrine responses to novelty stress in adult zebrafish) (DOI: 10.1038/nprot.2010.140); Egan et al. 2009, Behav. Brain Res. 205(1):38-44 (understanding behavioral and physiological phenotypes of stress and anxiety in zebrafish) (DOI: 10.1016/j.bbr.2009.06.022); Maximino et al. 2010, Behav. Brain Res. 214(2):157-171 (measuring anxiety in zebrafish: a critical review) (DOI: 10.1016/j.bbr.2010.05.031); Stewart et al. 2012, Neuropharmacology 62(1):135-143 (modeling anxiety using adult zebrafish: a conceptual review -- no operational threshold given) (DOI: 10.1016/j.neuropharm.2011.07.037); Kalueff et al. 2013, Zebrafish 10(1):70-86 (towards a comprehensive catalog of zebrafish behavior 1.0 and beyond) (DOI: 10.1089/zeb.2012.0861)"
IL-16,individual,optional,true,3-D Distance Travelled,Standard kinematics,,"Martin & Bateson 2007, Measuring Behaviour: An Introductory Guide, 3rd ed. (Cambridge University Press) (DOI: 10.1017/CBO9780511810893)"
IL-17,individual,optional,true,3-D Speed (mean / median / max),Standard kinematics,,"Bjorneraas et al. 2010, J. Wildl. Manage. 74(6):1361-1366 (screening GPS location data for errors using animal movement characteristics) (DOI: 10.2193/2009-405)"
+IL-18,individual,optional,true,Vertical Speed (mean / median / max),Standard kinematics,,"Bjorneraas et al. 2010, J. Wildl. Manage. 74(6):1361-1366 (screening GPS location data for errors using animal movement characteristics) (DOI: 10.2193/2009-405)"
Z-1,zone,primary,false,Time in zone,"Walsh & Cummins 1976, Psychol. Bull. 83(3):482-504 (the open-field test: a critical review)",10.1037/0033-2909.83.3.482,"Hall 1934, J. Comp. Psychol. 18(3):385-403 (emotional behavior in the rat: I. Defecation and urination as measures of individual differences in emotionality) (DOI: 10.1037/h0071444)"
Z-2,zone,primary,false,Area-Corrected Occupancy,"Area-normalised occupancy (observed time in a zone relative to that zone's share of the arena), the standard correction for comparing unequal-area regions of interest. No single originating work",,"Jacobs 1974, Oecologia 14(4):413-417 (quantitative measurement of food selection -- origin of the bias-corrected D electivity index) (DOI: 10.1007/BF00384581); Krause & Ruxton 2002, Living in Groups (Oxford University Press) (DOI: 10.1093/oso/9780198508175.001.0001)"
Z-3,zone,primary,true,Zone visit count,"Martin & Bateson 2007, Measuring Behaviour: An Introductory Guide, 3rd ed. (Cambridge University Press)",10.1017/CBO9780511810893,"Bakeman & Gottman 1997, Observing Interaction: An Introduction to Sequential Analysis, 2nd ed. (Cambridge University Press) (DOI: 10.1017/CBO9780511527685); Sibly et al. 1990, Anim. Behav. 39(1):63-69 (splitting behaviour into bouts) (DOI: 10.1016/S0003-3472(05)80726-2)"
diff --git a/docs/dev/DECISIONS.md b/docs/dev/DECISIONS.md
index bbd02cd5..c4494399 100644
--- a/docs/dev/DECISIONS.md
+++ b/docs/dev/DECISIONS.md
@@ -820,7 +820,8 @@ preprocessing cache key, which a view never changes). *Gate.* A metric names the
meaningful for in `Metric.valid_camera_views` (a set; `None` = any). A metric the view rules out
is greyed, skipped with a reason that reaches the run README, and never silently computed on the
wrong axis. *Existing metrics are deliberately not gated:* IL-3 and IL-14 assume a top-down view,
-but gating them would change what existing projects get. *IL-15, not Z-10.* Exports bucket
+but gating them would change what existing projects get. (Superseded by D-045: IL-3 and IL-14 are now
+gated to top-down and unset views.) *IL-15, not Z-10.* Exports bucket
metrics by ID prefix and the UI by level, so an individual-level metric must be IL-xx; Z-10 is
re-reserved for auto depth bands. *Depth.* 0 = water surface, 1 = tank floor. The water column is
the vertical span of every main-level "+" zone pooled (not per animal like IL-3), and when there
@@ -1169,3 +1170,89 @@ Sessions screen is the only consumer); the water column stays in the preprocess
object already includes `apply_calibration_and_zones`. *Limits:* `flip` reverses the vertical axis only, which
also mirrors the view from above; one origin for the water column cannot be checked from the files; no
refraction or parallax correction.
+
+
+### D-045 · IL-3 and IL-14 are plan-view metrics: gated to top-down and unset camera views
+
+**Status:** accepted; implemented. It supersedes the sentence in D-036 that left IL-3 and IL-14 ungated.
+
+**Decision:** `CentreDistance` (IL-3) and `WallDistanceThigmotaxis` (IL-14) declare
+`valid_camera_views = {"top", "unknown"}`. On a project whose camera view is declared `side` they are greyed
+on the Metrics screen, skipped with the view reason (which reaches the run README), and left out of the
+presets. `unknown` stays in the set, so a project that never declared a view, which is every project created
+before the setting existed, keeps both metrics with the same values. A fused session counts as top-view
+whatever the project says (`Engine.camera_view_for_psess`), so fused and native 3-D sessions are unchanged;
+a native project keeps its own pixel-frame reason for these metrics, which is checked first.
+
+**Why:** the arena centre and the wall distance are defined on the plan view; on a side view the image's
+vertical axis is depth, so the numbers were computed but meant nothing. D-036 held them back only to avoid
+changing existing projects, and the `unknown` member of the set removes that cost.
+
+**Not changed:** no metric value, exported file or `project_hash` for a project with an unset or top-down
+view.
+
+
+### D-046 · "Already in the project" is one rule, and a split folder is offered again as a whole-frame session
+
+**Status:** accepted; implemented.
+
+**Decision:** `core/session_identity.same_session_part(candidate, existing)` is the single test for whether
+the project already holds a session. Same resolved folder, `existing.reader` empty or equal to the
+candidate's, equal session-scope options (an arena), and equal `panel`. It is deliberately not symmetric: only
+a legacy existing entry without a reader is a wildcard. `ProjectStore._is_duplicate` and the import dialog's
+`ConfirmDraft` both call it. Before, the dialog compared folder and reader only, so a folder whose whole-frame
+session had been split into `__top` and `__side` panels was reported "already in the project" on rescan and
+could not be re-added, while the store would have accepted it.
+
+**Why:** two copies of the rule had drifted. A panel is another part of the video, so it is another session.
+
+### D-047 · Frame rate and size can be read from a chosen video, never silently
+
+**Status:** accepted; implemented.
+
+**Decision:** `readers/video_meta.probe_video(path)` returns `VideoProbe(fps, width_px, height_px, n_frames)` or None (never raises; None for missing `av`, an unopenable file, or non-positive fps/size). The import dialog's "Read from video…" button, enabled only when the reader has an `fps`, `width_px` or `height_px` option, calls `ConfirmDraft.prefill_from_video`, which sets those options with source "video" and never overwrites a "user"-sourced value. An unreadable video shows a warning and changes nothing.
+
+**Why:** DeepLabCut and SLEAP files carry neither value, and asking people to type them invites the silent-fps errors fixed for the v5 reader. The probe is explicit and user-triggered, so nothing is guessed.
+
+### D-048 · A reader the scan did not recognise can be tried again, with only its extension loosened
+
+**Status:** accepted; implemented.
+
+**Decision:** `ScanResult` keeps the walk (`index`, dropped by `release_index()` when the confirm dialog closes, never stored on a manifest). `SessionReader.discover_lenient(index, peek)` (default: nothing) reports files `discover` refused; `scan.lenient_scan(result, name)` returns them as one LOW-confidence group, and `ConfirmDraft.offer_reader(name)` makes it the first group, so `select_reader` can then name it. Only DeepLabCut (`.txt`, `.dat`, no extension) and SLEAP (`.analysis`, `.hdf`, `.dat`, no extension) implement it, and each still requires the full header or layout.
+
+**Why:** a forced reader on files it cannot read only moves the error. Loosening the name and not the content means a wrong guess still fails with the reader's own coded error instead of producing a session of guesses.
+
+### D-049 · The `movement` adapter is a function, not an exporter, and identity probability is never `confidence`
+
+**Status:** accepted; implemented.
+
+**Decision:** `interop/movement.to_movement_dataset(psess)` builds an `xarray.Dataset` from a `PreprocessedSession`; it is not registered as an exporter because an exporter payload carries metric results and no position array. `time` comes from `psess.timeline()` over `fps`, not `arange`, so tracking gaps survive. The tracker's identity probability is `identity_confidence`. `length_unit` in the attrs is the unit of `position` (`px`, or the native file's unit), and `px_per_cm` is NaN when not calibrated. `xarray` is an optional extra imported inside the function (`XARRAY_MISSING`).
+
+**Why:** `movement` filters on `confidence` as pose certainty; putting identity certainty there would invite a filter on the wrong quantity.
+
+### D-050 · G-H5: DeepLabCut `.h5` is decoded with h5py and a restricted unpickler, not PyTables
+
+**Status:** accepted for the synthetic layout; **not yet checked against the real EPM file** (see Evidence).
+
+**Decision:** option (a). DeepLabCut writes `df.to_hdf(path, key="df_with_missing", format="table")`. h5py reads the `table` compound dataset (`index` and one or more `values_block_N` arrays), and the column labels, which pandas stores as protocol-0 pickles in the group's `non_index_axes` and each dataset's `values_block_N_kind` attribute, are decoded by an unpickler that denies every global and caps each attribute's size. PyTables (`tables`) is not imported and not a dependency of the shipped reader. The scan never unpickles: it only sees that a top-level group holds a `table` dataset whose plain-text `pandas_type` attribute is `frame_table`.
+
+**Evidence:** a table-format file written by pandas 2.x with PyTables 3.x under key `df_with_missing`, with three-level `(scorer, bodyparts, coords)` columns, was opened with plain h5py. The group's `pandas_type` is `b'frame_table'`; `table` is a `(R,)` compound of `index` (int64) and `values_block_0` (float64, `(C,)`); `non_index_axes` unpickles to `[(1, [(scorer, bodypart, coord), ...])]` and `values_block_0_kind` to the same list of tuples. Both pickles are protocol 0 and contain only lists, tuples, strings and integers, so an unpickler that refuses every global decodes them.
+
+**Limit of the evidence:** the real sample (`DLC_single-mouse_EPM.predictions.h5`, 18485 x 24) could not be downloaded here (the host is not on this environment's network allowlist, HTTP 403), so its 18485 frames and 8 bodyparts are not re-verified. Until a run with the fixture passes, the reader is `synthetic_only`, and the real-sample tests skip when the file is absent. If the real file needs a global in its label pickle, the unpickler refuses it with `DLCH5_LABELS_REFUSED` instead of widening the allowlist silently; option (b) (an optional `hdf` extra with `tables`, behind the pickle-consent gate) is then the fallback.
+
+### D-051 · The metadata sheet is stored only once chosen, so existing project hashes do not move
+
+**Status:** accepted; implemented.
+
+**Decision:** `MetadataSource.sheet` (default None, the first sheet) is left out of the serialised manifest while it is None, by a wrap `model_serializer`. The screen stores a sheet name only for a workbook with more than one sheet. `loader.load(path, sheet)`, `list_sheets` and `preview` share one sheet check, and `.xls` is refused outright.
+
+**Why:** `project_hash` is a dump of the whole manifest, so a plain `sheet: null` field would change the hash (and the cache and run identity built on it) of every project that already has a metadata file.
+
+### D-052 · IL-18, GL-18 and GL-19 reuse the 3-D helpers; GL-19 defaults to the nearest-neighbour source
+
+**Status:** accepted; implemented.
+
+**Decision:** IL-18 takes the vertical component of the 3-D velocity (`session.z_speed` for native files, otherwise `geometry3d.vertical_speed`), so it follows the session's kinematics estimator exactly as IL-17 does. GL-18 averages the distance over all animal pairs per frame using the same frame filter as GL-16 (a frame with any missing animal is skipped and counted); a single-animal session is NaN. GL-19 is the reciprocal of the mean 3-D distance, with `cohesion_source` of `nnd` (default, matching GL-6) or `iid`. GL-16, GL-18 and GL-19 share `_mean_body_length_cm` and `_frame_distances_3d`.
+
+**Why:** one frame rule and one body-length rule keep the three group metrics comparable, and the NND default keeps GL-19 the direct 3-D analogue of GL-6.
+
diff --git a/docs/dev/ROADMAP.md b/docs/dev/ROADMAP.md
index d063afd1..569b58d4 100644
--- a/docs/dev/ROADMAP.md
+++ b/docs/dev/ROADMAP.md
@@ -1,7 +1,7 @@
# Track2Data — Implementation Roadmap
**Status:** M1–M4 complete. M5 (v1.0 release) is the only milestone left.
-**Last updated:** 2026-08-21
+**Last updated:** 2026-10-15
---
@@ -26,12 +26,12 @@ The engine, the GUI, and the packaging pipeline are all built and green.
| Full error hierarchy (`core/errors.py`) | ✅ Implemented |
| Manifest read/write + migration (`core/manifest.py`) | ✅ Implemented |
| Unified idtracker.ai reader (`readers/idtrackerai/`) — h5 / npy / csv | ✅ Implemented; 70/70 real corpus sessions import |
-| Behavioural metrics | ✅ 54 registered (IL-1..11, IL-14..17, GL-1..11, GL-13, GL-15, GL-16, Z-1..9, D-1..16) |
+| Behavioural metrics | ✅ 57 registered (IL-1..11, IL-14..18, GL-1..11, GL-13, GL-15, GL-16, GL-18, GL-19, Z-1..9, D-1..16) |
| Exporters | ✅ 5 (`csv_long`, `csv_wide`, `excel`, `feather`, `readme`) |
| Metadata join wired into `Engine` | ✅ Implemented |
| Desktop GUI (`app/` + `ui/`) | ✅ Wizard wired end-to-end to the engine |
| Standalone binaries (Windows / macOS / Linux) | ✅ Built + validated in CI |
-| Test suite | ✅ ~1760 passing (plus `r_parity` and `corpus_local` gates) |
+| Test suite | ✅ ~4990 collected (4769 passed, 219 skipped on the last full local run; plus `r_parity` and `corpus_local` gates) |
| Preprocessed-session cache, parallel runs, in-session cancel | ✅ Implemented (D-019, D-020); parallel not yet benchmarked on the real corpus |
| Trajectory viewer, zone tools, calibration ruler, stage status / Next gating | ✅ Implemented (D-021, D-022) |
| User guide with screenshots (`docs/guide/`) | ✅ Generated from the real app |
@@ -116,13 +116,13 @@ re-reading why each was deferred:
|---|---|---|
| Z-10 | Auto depth bands: time in the top / middle / bottom third of the water column, with no zones to draw | The prerequisite shipped: the project-level `camera_view` setting (D-036) and IL-15, the continuous depth statistic. Stacked secondary-level zones already give band occupancy, latency, visits and dwell through Z-1..Z-9, so this is only a convenience. Re-reserved here because the original "vertical-position" proposal became IL-15 (an individual-level metric cannot carry a Z- prefix). |
| GL-17 | Individual consistency / repeatability of social position | Needs repeated trials. `Metric.compute(session, cfg)` is per-session with no cross-session concept -- an architectural change, not a metric. |
-| D-11 | Effective sample size / autocorrelation-adjusted N | Viable, medium effort, no blocker -- deferred on scope alone this round. |
-| D-12 | Interpolation & gap provenance per metric | `PreprocessedSession.was_interpolated` already exists, but *per-metric* provenance requires every metric to report which frames fed it -- a change to the `Metric` contract, not one new class. |
+| D-17 | Effective sample size / autocorrelation-adjusted N | Viable, medium effort, no blocker -- deferred on scope alone this round. |
+| D-18 | Interpolation & gap provenance per metric | `PreprocessedSession.was_interpolated` already exists, but *per-metric* provenance requires every metric to report which frames fed it -- a change to the `Metric` contract, not one new class. |
| IL-12 | Speed autocorrelation / persistence time | Viable, medium effort, no blocker -- deferred on scope alone this round. |
| IL-13 | Bout / kinematic-state segmentation | The proposal's source (Marques et al. 2018) segmented bouts from **tail shape at ~700 fps**; citing it for a centroid-based segmenter would repeat the exact over-claim the audit flagged on IL-5's tortuosity estimator. |
| GL-12 | Neighbour angular distribution / density map | Viable, but its natural output is a 2-D histogram, which doesn't fit the long-format `session_id \| individual_id \| metric_id \| value` schema -- needs summary scalars designed first. |
| GL-14 | Directional correlation delay / leadership ranking | D-7 measured a **median individual-fragment length of 3 frames** on a real corpus session; lag correlation needs identity to hold across seconds, which this corpus does not support. |
-| GL-16 | Group spatial correlation length | Cavagna's method is validated on flocks of hundreds; on shoals of 5-20 animals the correlation length is not estimable. |
+| GL-20 | Group spatial correlation length | Cavagna's method is validated on flocks of hundreds; on shoals of 5-20 animals the correlation length is not estimable. |
### Done: identity-free zone metrics (SCI-02, D-016)
@@ -153,5 +153,5 @@ The repository is public (see M5).
- [`CONTRIBUTING.md`](../../CONTRIBUTING.md) — dev setup, TDD workflow, branch policy
- [`docs/TECHNICAL_SPEC.md`](../TECHNICAL_SPEC.md) — system architecture, testing strategy
- [`docs/ENGINE_DESIGN.md`](../ENGINE_DESIGN.md) — engine internals and module layout
-- [`docs/METRICS_SPEC.md`](../METRICS_SPEC.md) — 54 behavioural metrics with formulas and citations
+- [`docs/METRICS_SPEC.md`](../METRICS_SPEC.md) — 57 behavioural metrics with formulas and citations
- [`docs/dev/UI_DESIGN.md`](UI_DESIGN.md) — 14-screen PySide6 GUI specification
diff --git a/docs/guide/USER_GUIDE.md b/docs/guide/USER_GUIDE.md
index 3ab802d0..18e251b3 100644
--- a/docs/guide/USER_GUIDE.md
+++ b/docs/guide/USER_GUIDE.md
@@ -431,12 +431,13 @@ Further down the same screen, **Camera view** says how the camera looked at the
set** (the default), **Top-down**, or **Side view**. It is saved with the project, separately from
the calibration, and a project that never sets it behaves exactly as before.
-Some metrics only make sense for one view. Today that is **Vertical Position (Depth)**, IL-15,
-which needs a side view: on a top-down recording the image's vertical axis is not depth. Until you
-declare a side view, that row is greyed on the Metrics screen and its tooltip says how to switch
-it on; if it is selected anyway, the run skips it and the run's README says so. Declaring a view
-does not switch off any existing metric. Thigmotaxis (IL-14) and distance from the centre (IL-3)
-still assume you are looking down on the arena, so read them accordingly on a side view.
+Some metrics only make sense for one view. **Vertical Position (Depth)**, IL-15, needs a side view:
+on a top-down recording the image's vertical axis is not depth. Until you declare a side view, that
+row is greyed on the Metrics screen and its tooltip says how to switch it on; if it is selected
+anyway, the run skips it and the run's README says so. The opposite holds for **Thigmotaxis**
+(IL-14) and **Distance from the Arena Centre** (IL-3): they describe the arena seen from above, so
+declaring a **Side view** greys them out and a run skips them. **Not set** and **Top-down** keep
+both, so a project that never declared a view is unchanged.
For depth, draw a **main** zone from the waterline to the floor on the Zones screen. The top edge
is the surface and the bottom edge the floor; Track2Data never substitutes the video frame, so with
@@ -523,7 +524,9 @@ Attach experimental information (treatment, date, tank, …) to every row of the
Figure 11. The Metadata screen: CSV preview, column mapping and the match summary.
-1. **Load metadata CSV…**. The first rows are previewed.
+1. **Load metadata file…** (a CSV or an Excel `.xlsx` workbook). The first rows are previewed. If a
+ workbook has several sheets, a **Sheet** menu appears: pick the one that holds your metadata (the
+ first sheet is used until you do). The older `.xls` format is not read; save it as `.xlsx`.
2. Match columns to fields. Names like `date`, `condition` or `tank` are matched automatically to
*Trial date*, *Treatment*, *Group ID*; change any you disagree with, or leave a field on
`(skip)`.
@@ -535,7 +538,7 @@ row is matched per session (if several rows match, the first is used and the sum
### Per-animal information (sex, weight, genotype of individual animals)
-Give the CSV **one row per animal** and map its animal column (`fish_id`, `animal_id` or any name)
+Give the file **one row per animal** and map its animal column (`fish_id`, `animal_id` or any name)
to *Individual ID*. Then:
- **Match animals by** decides how each value is matched to an animal. *Validator label* uses the
diff --git a/docs/superpowers/plans/2026-10-13-backlog-batch.md b/docs/superpowers/plans/2026-10-13-backlog-batch.md
index 992393c9..cb2698ba 100644
--- a/docs/superpowers/plans/2026-10-13-backlog-batch.md
+++ b/docs/superpowers/plans/2026-10-13-backlog-batch.md
@@ -65,21 +65,24 @@
**Files:**
- Modify: `ui/zones_screen.py` (`_refresh_canvas`, ~L475-490), `ui/widgets/zone_canvas.py` (`ZoneCanvas.load_session`, ~L255-320)
- Modify: `ui/widgets/panel_preview.py` (extract the existing `_video_frame` static method into a shared helper)
+- Modify: `track2data/readers/video_meta.py` (new `extract_frame_rgb(video_path, frame_index=0) -> tuple[bytes, int, int] | None` returning the bytes with the frame's own decoded `(width, height)` from the `av` frame; `extract_frame` becomes a thin wrapper that returns only the bytes, so its callers and mocks are unchanged)
- Create: `ui/widgets/video_frame.py` holding `decode_first_frame(video_path: Path | None, width_px: int, height_px: int) -> QImage | None`
-- Test: `tests/test_ui/test_zones_screen.py`, `tests/test_ui/test_zone_canvas.py`, `tests/test_ui/test_panel_preview.py`
+- Test: `tests/test_ui/test_zones_screen.py`, `tests/test_ui/test_zone_canvas.py`, `tests/test_ui/test_panel_preview.py`, `tests/test_readers/test_video_meta.py`
**Interfaces:**
-- Produces: `decode_first_frame(...)` returning `None` unless the path exists, `extract_frame(path, 0)` returns bytes and `len(data) == w * h * 3`. `ZoneCanvas.load_session(background_image_path, setup_points, frame_size=None, crop=None, video_path: Path | None = None)` falls back to `decode_first_frame` only when the backdrop image is missing and `crop is None`.
+- Produces: `decode_first_frame(...)` returning `None` unless the path exists, `extract_frame_rgb(path, 0)` returns a frame whose **reported `(width, height)` equals `(width_px, height_px)`** and `len(data) == w * h * 3`. A byte-length check alone is not enough: a rotated or transposed frame (1080x1920 against a declared 1920x1080) has the same `w * h * 3`, so the decoded dimensions are the guard and the length is only a sanity check. `ZoneCanvas.load_session(background_image_path, setup_points, frame_size=None, crop=None, video_path: Path | None = None)` falls back to `decode_first_frame` only when the backdrop image is missing and `crop is None`.
- Consumes: `SessionFacts.video_path` (already filled), `track2data.readers.video_meta.extract_frame`.
-- [ ] **Step 1: Write failing tests** (monkeypatch `extract_frame` as `tests/test_ui/test_panel_preview.py` does):
+- [ ] **Step 1: Write failing tests** (monkeypatch `extract_frame_rgb` (and `extract_frame` for the preserved `PanelPreview` path) as `tests/test_ui/test_panel_preview.py` does):
- `test_zone_canvas_uses_the_video_first_frame_when_there_is_no_background_image`
- `test_a_video_frame_of_the_wrong_size_is_rejected_and_the_canvas_stays_blank`
+ - `test_a_transposed_frame_with_the_same_byte_count_is_rejected` (mock `extract_frame_rgb` to return `(bytes(1920*1080*3), 1080, 1920)` against a declared 1920x1080)
+ - `test_extract_frame_rgb_reports_the_decoded_frame_size` (in `tests/test_readers/test_video_meta.py`, `av` mocked as `TestExtractFrame*` does)
- `test_a_cropped_panel_never_falls_back_to_the_video_frame`
- `test_zones_screen_passes_the_session_video_path_to_the_canvas`
- `test_panel_preview_still_reports_backdrop_kind_video` (refactor guard).
- [ ] **Step 2:** Run `py -3.14 -m pytest tests/test_ui/test_zones_screen.py tests/test_ui/test_zone_canvas.py tests/test_ui/test_panel_preview.py -q`. Expected: FAIL on the new tests.
-- [ ] **Step 3:** Implement the helper by moving `PanelPreview._video_frame` logic unchanged, have `PanelPreview` call it, add the `video_path` parameter to `ZoneCanvas.load_session`, and pass `facts.video_path` from `ZonesScreen._refresh_canvas`. The size check is the guard in Review Focus; the first saved zone stamps `source_*_px`, so a wrong-size frame must be refused, not scaled.
+- [ ] **Step 3:** Implement `extract_frame_rgb`, then the helper by moving `PanelPreview._video_frame` logic and swapping its length-only check for the decoded-size check (so `PanelPreview` gets the stricter guard too), have `PanelPreview` call it, add the `video_path` parameter to `ZoneCanvas.load_session`, and pass `facts.video_path` from `ZonesScreen._refresh_canvas`. The decoded-size check is the guard in Review Focus; the first saved zone stamps `source_*_px`, so a wrong-size frame must be refused, not scaled.
- [ ] **Step 4:** Run the three test files plus `tests/test_api.py -k preview_frame`, ruff, mypy. Expected: PASS.
- [ ] **Step 5:** Commit `feat(ui): show the video's first frame behind the zones canvas`.
@@ -92,15 +95,15 @@
- Test: `tests/test_readers/test_confirm.py` (class `TestSessions`), `tests/test_ui/test_project_store.py`, `tests/test_ui/test_import_screen.py`
**Interfaces:**
-- Produces: `same_session_part(a, b)`: same resolved folder, readers equal or either `None`, session-scope option values equal, and `a.panel == b.panel`. It lives in `track2data/core` so `confirm.py` and `project_store.py` both import it without the circular import that importing `find_reader` into `confirm.py` would cause. The session-scope option selector currently lives in `project_store._selector`; move it with the helper and pass the reader's parameters in rather than importing `find_reader` at module level.
+- Produces: `same_session_part(candidate: SessionRef, existing: SessionRef) -> bool`, deliberately **not symmetric**, copying `project_store._is_duplicate` exactly: same resolved folder; `existing.reader is None or existing.reader == candidate.reader` (only the *existing* ref's `None` is a wildcard, a candidate with `reader=None` matches only an existing ref that also has none or the same name); session-scope option values equal; and `existing.panel == candidate.panel`. It lives in `track2data/core` so `confirm.py` and `project_store.py` both import it without the circular import that importing `find_reader` into `confirm.py` would cause. The session-scope option selector currently lives in `project_store._selector`; move it with the helper and pass the reader's parameters in rather than importing `find_reader` at module level.
- Candidate refs from the dialog are `panel=None`; existing refs with a `panel` therefore no longer block them.
- [ ] **Step 1: Write failing tests**
- `test_a_whole_frame_candidate_is_offered_again_after_the_folder_was_split_into_panels` (existing `SessionRef` with `panel=PanelRect(...)`, candidate `panel=None`, `already_added is False`).
- `test_a_true_duplicate_is_still_left_out` (same folder, reader, selector, panel).
- - `test_project_store_and_confirm_agree_on_what_a_duplicate_is` (parametrized truth table run through both code paths).
+ - `test_project_store_and_confirm_agree_on_what_a_duplicate_is` (a fixed truth table written out as data, not derived from the implementation: existing reader `None`/`"a"` x candidate reader `None`/`"a"`/`"b"` x panel equal/different, run through both code paths; the asymmetric cases (existing `"a"` with candidate `None`) are listed explicitly as not duplicates).
- [ ] **Step 2:** Run `py -3.14 -m pytest tests/test_readers/test_confirm.py tests/test_ui/test_project_store.py -k "panel or duplicate" -q`. Expected: FAIL.
-- [ ] **Step 3:** Implement the shared helper, make `ConfirmDraft` keep its existing list as `SessionRef`s rather than `(folder, reader)` tuples, and make `_is_duplicate` delegate. Keep the legacy rule: `reader is None` on the existing ref matches any reader.
+- [ ] **Step 3:** Implement the shared helper, make `ConfirmDraft` keep its existing list as `SessionRef`s rather than `(folder, reader)` tuples, and make `_is_duplicate` delegate. Keep the legacy rule above and do not make it symmetric.
- [ ] **Step 4:** Run `tests/test_readers tests/test_ui/test_project_store.py tests/test_ui/test_import_screen.py tests/test_ui/test_panel_dialog.py -q`, ruff, mypy. Expected: PASS.
- [ ] **Step 5:** Commit `fix(import): a split folder is not "already in the project" when rescanned`.
@@ -151,9 +154,11 @@
- [ ] **Step 4:** Run the same files in full, plus `tests/test_readers/test_conformance.py`, ruff, mypy. Expected: PASS.
- [ ] **Step 5:** Commit `feat(import): read fps and frame size from a chosen video for DLC and SLEAP`.
+(Task 5 edits `ConfirmDraft._enter` and the confirm dialog, which Task 3 also touches: start it after Task 3 has landed.)
+
### Task 6: `lenient_detection` ("choose software…" re-run)
-**Depends on:** Task 3 (both edit `readers/confirm.py`).
+**Depends on:** Task 3 and Task 5. Tasks 3, 5 and 6 all edit `readers/confirm.py` (`ConfirmDraft`) and `ui/dialogs/confirm_format_dialog.py`; run them in that order on one line of branches, not in parallel, or the three will conflict.
**Files:**
- Modify: `track2data/readers/scan.py` (`ScanResult` keeps the `ScanIndex`; new `lenient_scan(result: ScanResult, reader_name: str) -> FormatGroup | None`), `track2data/readers/confirm.py` (`ConfirmDraft.offer_reader(name: str) -> bool`), `track2data/cli.py` (`add --reader` falls through to it), `ui/dialogs/confirm_format_dialog.py` (`_show_nothing_recognised` L212 gains a "Choose software…" combo)
@@ -197,25 +202,25 @@
- `test_scan_never_unpickles` (monkeypatch `pickle.loads` / `pickle.Unpickler` to raise; scanning a DLC h5 must not call them).
- `test_a_hostile_pickled_label_is_refused_with_a_coded_error`
- `test_sleap_and_dlc_h5_never_both_claim_a_file`
- - `test_the_epm_real_sample_is_read_to_18485_frames` (`real_sample` marker, `importorskip` as the oracle needs `tables`).
+ - `test_the_epm_real_sample_is_read_to_18485_frames` (`real_sample` marker; skips only when the fixture file is absent. It must run without `tables`: it reads with the shipped h5py path and asserts the shape and bodypart count recorded in the Step 1 spike (18485 frames, 8 bodyparts). The separate test `test_the_epm_real_sample_matches_the_pytables_oracle` is the one that uses `pytest.importorskip("tables")`, to cross-check values.)
- `test_a_tracks_group_h5_is_refused_with_a_remediation` (maDLC `_el.h5` layout).
- [ ] **Step 3:** Run `py -3.14 -m pytest tests/test_readers/test_deeplabcut_h5.py tests/test_readers/test_sleap_analysis.py -q`. Expected: new tests FAIL, existing SLEAP tests PASS.
- [ ] **Step 4:** Implement per the G-H5 outcome. Errors: `DLCH5_UNREADABLE`, `DLCH5_LABELS_REFUSED`, `DLCH5_LAYOUT_UNSUPPORTED`, each with `subject` and `remediation`.
-- [ ] **Step 5:** Run `tests/test_readers tests/test_scan tests/real_samples -q` (real-sample tests skip when fixtures are absent), `tests/test_readers/test_conformance.py`, ruff, mypy. Expected: PASS and the `dlc_single_animal_h5` contract case no longer XFAILs.
+- [ ] **Step 5:** Run `tests/test_readers tests/test_scan tests/real_samples -q` (real-sample tests skip when fixtures are absent), `tests/test_readers/test_conformance.py`, ruff, mypy. Expected: PASS and the `dlc_single_animal_h5` contract case no longer XFAILs. Check that the run did not skip the real-sample reader test: with the fixture present and `tables` absent it must still execute (`-rs` shows the skip reasons).
- [ ] **Step 6:** Commit `feat(readers): DeepLabCut .h5 predictions`.
### Task 8: Fix the stale docs
-**Depends on:** Tasks 1-7 and 9-11 (write the final statements last; re-run this task's numbers at the end).
+**Depends on:** Tasks 1-7 and 9-11, including Task 6 (it closes the tier-0 deferred notes this task would otherwise leave open). Task 8 runs last of all the code tasks and re-measures every number, so it never blocks them.
**Files:**
- Modify: `docs/dev/ROADMAP.md` (L3-4 status and date; L34 "~1760 passing"; M5 checklist L52 onward: first tag and branch protection are done, L80 "not yet applied" is false; reserved-ID table L106-125: D-11 is Metric Input Provenance, D-12 is Fragment Quality Scores, GL-16 is 3-D NND, so reassign their old proposals to unused IDs D-17, D-18 and GL-20, and keep GL-17 reserved; L157 screen count), `docs/INTEROPERABILITY.md` (add `anipose` and `deeplabcut_3d` rows, keep `trx.mat` "synthetic only"), `docs/tracker-formats/README.md` (L61, L121, L129 `trx.mat` is built, synthetic only), `docs/tracker-formats/2026-10-08-tier-2-implementation-plan.md` (L9, L44-49), `docs/IDTRACKERAI_FORMAT_ANALYSIS.md` (§3 gap table L177-207: re-check each row against `track2data/readers/idtrackerai/*` and mark done rows)
-- Modify: every `- [ ]` in `docs/superpowers/plans/2026-10-09-*.md`, `2026-10-10-*.md`, `2026-10-11-*.md` and `docs/tracker-formats/2026-10-07-tier-0-implementation-plan.md` becomes `- [x]` (all shipped in v0.3.0); this plan's own boxes are ticked by the executor as it goes
+- Modify: the `- [ ]` boxes in `docs/superpowers/plans/2026-10-09-*.md`, `2026-10-10-*.md`, `2026-10-11-*.md` and `docs/tracker-formats/2026-10-07-tier-0-implementation-plan.md` (all currently unticked, 339 boxes). A box is ticked only after it is checked against the repository: the task's commit (`git log --oneline --grep`), its named test (`grep -rn "def " tests`) or file exists, or the `[0.3.0]` section of `CHANGELOG.md` records the feature. A box that cannot be verified stays `- [ ]` with a one-line `Status:` note under its task (not shipped, deferred, or superseded and by what). The tier-0 deferred items (T0-4, T0-6) are ticked only if Task 6 shipped them. This plan's own boxes are ticked by the executor as it goes
- Modify: `docs/dev/DECISIONS.md` only if a numbering fix needs a note
- Test: `tests/test_docs_tracker_formats.py` (pins sha256 of research files and README rows; run it before and after), `tests/test_metric_references_consistency.py`, `tests/test_docs`
- [ ] **Step 1:** Re-measure: `py -3.14 -m pytest --collect-only -q` (tail) for the test count, `len(track2data.metrics._registry)` for the metric count, and write the numbers into ROADMAP as "collected", not "passing".
-- [ ] **Step 2:** Edit the files above. Do not edit files that `tests/test_docs_tracker_formats.py` hashes (the research files); change only the README rows it keeps in step.
+- [ ] **Step 2:** Edit the files above, plan by plan, writing the verification evidence (commit subject or test name) in the commit message for the ticked set. Do not edit files that `tests/test_docs_tracker_formats.py` hashes (the research files); change only the README rows it keeps in step.
- [ ] **Step 3:** Run `py -3.14 -m pytest tests/test_docs_tracker_formats.py tests/test_metric_references_consistency.py tests/test_docs -q`. Expected: PASS.
- [ ] **Step 4:** Commit `docs: bring the roadmap, interoperability table and shipped plans up to date`.
@@ -227,11 +232,11 @@
- Test: `tests/test_metadata/test_schema.py`, `tests/test_ui/test_metadata_screen.py`
**Interfaces:**
-- Produces: `load(path: Path, sheet: str | None = None) -> pd.DataFrame` (`None` keeps today's behaviour, first sheet), and `list_sheets(path: Path) -> list[str]` (`[]` for CSV). A sheet name that is absent raises `ValueError` with the available names in the message. The new `MetadataSource.sheet` defaults to `None` so existing manifests load unchanged and `project_hash` is unchanged when no sheet is chosen.
+- Produces: `load(path: Path, sheet: str | None = None) -> pd.DataFrame` (`None` keeps today's behaviour, first sheet), and `list_sheets(path: Path) -> list[str]` (`[]` for CSV). A sheet name that is absent raises `ValueError` with the available names in the message. The new `MetadataSource.sheet` defaults to `None` so existing manifests load unchanged. A plain pydantic field would serialise as `"sheet": null` and change `project_hash` (a `model_dump` of the whole manifest) for every project that has a metadata source, so `MetadataSource` gets a wrap `model_serializer` that omits `sheet` when it is `None`; `project_hash` and the saved JSON are then byte-identical until a sheet is chosen.
- [ ] **Step 1: Write failing tests** (workbooks built with openpyxl, already in the `export` extra and `dev`):
- `test_load_xlsx_defaults_to_the_first_sheet`, `test_load_xlsx_reads_a_named_sheet`, `test_an_unknown_sheet_name_lists_the_available_ones`, `test_list_sheets_of_a_csv_is_empty`
- - `test_a_manifest_without_a_sheet_keeps_its_hash`
+ - `test_a_manifest_without_a_sheet_keeps_its_hash` (a manifest that **has** a `MetadataSource`, hash and `model_dump_json` compared with the values computed before the change; the pinned golden projects have no metadata source, so they alone would not catch this) and `test_a_chosen_sheet_is_saved_and_changes_the_hash`
- UI: `test_loading_an_xlsx_previews_it_and_offers_a_sheet_choice`, `test_choosing_a_second_sheet_persists_it_on_the_store`, `test_reopened_project_restores_the_chosen_sheet`
- [ ] **Step 2:** Run `py -3.14 -m pytest tests/test_metadata tests/test_ui/test_metadata_screen.py -k "sheet or xlsx" -q`. Expected: FAIL.
- [ ] **Step 3:** Implement. `.xls` stays declared but unsupported by openpyxl: raise a clear `ValueError` instead of the current silent failure.
@@ -261,7 +266,7 @@
### Task 11: Standalone 3-D metrics (IL-18, GL-18, GL-19)
-**Depends on:** Tasks 1 and 8 for their doc edits to settle first; this task changes the registered count from 54 to 57.
+**Depends on:** Task 1 only (both edit `ui/metrics_screen.py` presets and the metric docs). This task changes the registered count from 54 to 57 and must update the four pinned prose counts itself, because `test_metric_counts_stated_in_the_docs_match_the_registry` fails otherwise; Task 8 then runs after it and re-measures, it is not a prerequisite.
**Files:**
- Modify: `track2data/metrics/individual.py` (new `VerticalSpeed3D`, registered after `Speed3D`), `track2data/metrics/group.py` (new `InterIndividualDistance3D`, `GroupCohesion3D`, registered after `NearestNeighbourDistance3D`), `track2data/metrics/references.py` only if a needed reference is missing
@@ -305,10 +310,10 @@
## Execution order and dependencies
-1. Task 0 (user), then Tasks 1, 2, 3 (small, independent; 3 before 6).
-2. Tasks 4, 5, 7, 9, 10 are independent of each other; 5 and 7 both touch reader parameters, so land 5 first.
-3. Task 6 after 3.
-4. Task 11, then Task 8 last of the doc work (counts and statuses must be final).
+1. Task 0 (user), then Tasks 1, 2, 3 (small, independent of each other).
+2. Tasks 3, 5 and 6 share `readers/confirm.py` and the confirm dialog, so they land strictly in the order 3, 5, 6.
+3. Tasks 4, 7, 9, 10 are independent of each other and of the 3-5-6 line; 5 and 7 both touch reader parameters, so land 5 before 7.
+4. Task 11 after Task 1, then Task 8 last of all (counts and statuses must be final; Task 8 does not block 11).
5. Task 12 only after the layout decision.
Suggested branches: one per task from `main`, merged through PRs so `CI passed` gates each.
diff --git a/docs/tracker-formats/2026-10-07-tier-0-implementation-plan.md b/docs/tracker-formats/2026-10-07-tier-0-implementation-plan.md
index 618107b2..d74fc2b8 100644
--- a/docs/tracker-formats/2026-10-07-tier-0-implementation-plan.md
+++ b/docs/tracker-formats/2026-10-07-tier-0-implementation-plan.md
@@ -1337,7 +1337,7 @@ In `test_reader_contract.py` replace `registry.detect_reader(folder)().read(fold
**Deferred from the original outline**, until something needs it:
-- `lenient_detection` (the "it is actually X" re-run): needed by the dialog's "Choose software…" path, so it arrives with T0-6 and T0-8.
+- `lenient_detection` (the "it is actually X" re-run): needed by the dialog's "Choose software…" path, so it arrives with T0-6 and T0-8. **Built** later as `lenient_scan` / `SessionReader.discover_lenient` (D-048).
- The HDF5, MAT, zip and SQLite peeks: they arrive with the first reader that uses each.
- The golden table over the suite's ten case folders: it arrives with the first real reader (T1-2), when there is something to expect.
@@ -1389,7 +1389,7 @@ In `test_reader_contract.py` replace `registry.detect_reader(folder)().read(fold
- `readers/confirm.py`: `ConfirmDraft(result, existing=...)`, Qt-free. It starts on the best reader of the first group with every session included and the options pre-filled from `Detection.proposed` (each value remembers where it came from). `select_group`, `select_reader` (only a reader that recognised the group; choosing another marks the choice as the user's and starts that reader's options afresh), `set_option` / `set_session_option` (shared or per-session, by the parameter's `scope`; `None` clears), `set_included`, `rename`. `rows` are `SessionRow`s with the original id as the stable handle. `problems()` lists everything between the user and "Add": `NOTHING_SELECTED`, `OPTION_MISSING`, `OPTION_INVALID` (by the reader's own validation), `ID_UNSAFE`, `ID_DUPLICATE` (against the other chosen sessions and against the project). A session already in the project under the same reader (or a legacy entry with none) is left out and flagged; the same folder under another reader is another session. `to_session_refs()` saves the reader, the options that were *given* (not the defaults), who chose it and the confidence.
- CLI: `track2data list-readers [--json]`, `track2data scan ROOT... [--max-depth N] [--json]`, `track2data add PROJECT ROOT... [--group N] [--reader NAME] [--option NAME=VALUE] [--exclude ID] [--rename OLD=NEW] [--max-depth N] [--yes] [--dry-run]`, and `track2data/__main__.py` so `python -m track2data` runs the code on the current path. `add` shows the suggestion, applies the amendments, refuses with the reader's own wording while a required option is missing, asks for confirmation (`--yes` skips it, `--dry-run` shows the plan), and writes the project only after that. Nothing recognised exits non-zero and lists the file types it did see, and says how to look deeper.
-**Deferred.** `lenient_detection` (offering a reader that did *not* recognise the files, reusing the group's candidates) arrives with the first reader that needs it (Tier 1): with only idtracker.ai readers there is nothing meaningful to offer, and a forced reader on files it cannot read only moves the error.
+**Deferred.** `lenient_detection` (offering a reader that did *not* recognise the files, reusing the group's candidates) arrives with the first reader that needs it (Tier 1): with only idtracker.ai readers there is nothing meaningful to offer, and a forced reader on files it cannot read only moves the error. **Built** (D-048): `ConfirmDraft.offer_reader`, the dialog's "Choose software…" row and `add --reader` fall through to it; DeepLabCut and SLEAP loosen the file extension only.
**Verified.** 45 `ConfirmDraft` tests and 29 CLI tests; mutations of the draft (the dedupe rule, `chosen_by`, option reset on a reader change, defaults frozen into the entries, excluded sessions still added, duplicate and unsafe ids, per-session options) and of the commands (no confirmation, ignored flags, a dry run that writes, ...) are each caught.
diff --git a/docs/tracker-formats/2026-10-08-tier-1-implementation-plan.md b/docs/tracker-formats/2026-10-08-tier-1-implementation-plan.md
index 914e8782..b5143aee 100644
--- a/docs/tracker-formats/2026-10-08-tier-1-implementation-plan.md
+++ b/docs/tracker-formats/2026-10-08-tier-1-implementation-plan.md
@@ -59,8 +59,8 @@ layouts, `.slp` projects (the scan names them and says to export the analysis fi
- `dense_from_long` / `dense_from_segments` and `classify_identity_regime`: no Tier 1 reader needs
them (DeepLabCut and SLEAP are already dense, by frame). They arrive with the first reader that
does (Ctrax / TRex, Tier 2 and 4), so they are written against a real file.
-- `probe_video` (PyAV, to prefill fps and frame size): optional, arrives with the dialog work in
- T1-2 if the metadata pickle (G-meta) does not already answer it.
+- `probe_video` (PyAV, to prefill fps and frame size): **built** (D-047), behind the import dialog's
+ "Read from video…" button.
- A long-form export of the skeleton: stored only, as decided.
**Decisions recorded.**
diff --git a/docs/tracker-formats/2026-10-08-tier-2-implementation-plan.md b/docs/tracker-formats/2026-10-08-tier-2-implementation-plan.md
index e40cd58f..3b194fa6 100644
--- a/docs/tracker-formats/2026-10-08-tier-2-implementation-plan.md
+++ b/docs/tracker-formats/2026-10-08-tier-2-implementation-plan.md
@@ -19,7 +19,9 @@ cannot be misread as 193 animals; and the choice is recorded (`reader_options`)
export can see it. A file whose slots would not fit in memory (over 1 GiB of positions) is refused
with a request to set `top_n` rather than run out of memory.
-**Left for the Tier 2 follow-up.** Group metrics that skip frames with any NaN will see mostly-NaN
+**Built later (pre-flight advisory).** `readers/advisories._fragmented` warns, in `track2data validate` and the run README, when at least half the animal slots of a multi-animal session are empty, and points to `top_n`. The GUI has no screen that shows these advisories yet.
+
+**Was left for the Tier 2 follow-up.** Group metrics that skip frames with any NaN will see mostly-NaN
slots on a fragment-heavy file. That is a property of the data, not something to hide; a pre-flight
note saying so is a separate piece of work (it needs the advisories to know about fragmentation).
diff --git a/docs/tracker-formats/README.md b/docs/tracker-formats/README.md
index 71bd5ae4..4ec31a4d 100644
--- a/docs/tracker-formats/README.md
+++ b/docs/tracker-formats/README.md
@@ -56,9 +56,9 @@ reader fixture-test suite that must go from XFAIL to PASS when the reader lands.
| Prio | Importer | Tier | Status | Suite cases |
|---|---|---|---|---|
-| 1 | DeepLabCut CSV (Lightning Pose, EKS, maDLC) | 1 | **built** (CSV); `.h5` planned | `dlc_two_mice_csv`, `lightning_pose_eks_csv` pass; `dlc_single_animal_h5` waits for the G-H5 decision |
+| 1 | DeepLabCut CSV (Lightning Pose, EKS, maDLC) and `.h5` | 1 | **built** (CSV real-sample; `.h5` synthetic-only until the real EPM file has been read in a run, D-050) | `dlc_two_mice_csv`, `lightning_pose_eks_csv` pass; `dlc_single_animal_h5` is claimed by `deeplabcut_h5` and needs the fixture to run |
| 2 | SLEAP analysis HDF5 | 1 | **built** | `sleap_named_tracks`, `sleap_no_tracks` pass |
-| 3 | Ctrax raw `.mat` (**built**), `trx.mat`, FlyTracker | 2 | raw `.mat` built; `trx.mat` waits for a real sample; FlyTracker recognise-only | `ctrax_raw_mat` passes |
+| 3 | Ctrax raw `.mat` (**built**), `trx.mat`, FlyTracker | 2 | raw `.mat` built; `trx.mat` **built**, synthetic only (no real sample yet); FlyTracker recognise-only | `ctrax_raw_mat` passes |
| 4 | ToxTrac `Tracking_RealSpace.txt` | 3 | planned | `toxtrac_realspace` |
| 5 | Anipose pose-3d CSV (and DLC-3D) | 3 | **built** (both; synthetic files only, `verification = "synthetic_only"`) | quirk tests, and synthetic-file reader tests; the real-sample contract case runs when its fixture is present |
| 6 | TRex `.npz` | 4 | planned | `trex_new_export`, `trex_old_export` |
@@ -126,4 +126,4 @@ The file Ctrax's "Save Tracks as Matlab File" writes. Verified against the real
| Identity | Track ids are **fragments**, not animals: Ctrax starts a new id each time it loses a track. Every id becomes a slot, the session is identity-free by construction (`track_wo_identities`), and the scan says so. `top_n` keeps the longest tracks (ties to the lower id). A file whose slots would not fit in memory is refused with a request to set `top_n`. |
| Frame offset | `startframe` greater than 0 is recorded as the tracking interval, so true frame numbers are kept. |
| Missing | A frame with no detection for a track is NaN. |
-| Not yet | `trx.mat` (needs a real sample), v7.3 files, ellipse axes and angle (`maj_ax`, `min_ax`, `angle` are not used). |
+| Not yet | v7.3 files, ellipse axes and angle (`maj_ax`, `min_ax`, `angle` are not used). |
diff --git a/docs/wiki/Known-Issues.md b/docs/wiki/Known-Issues.md
index 6980e1cf..bdb06fd6 100644
--- a/docs/wiki/Known-Issues.md
+++ b/docs/wiki/Known-Issues.md
@@ -107,7 +107,7 @@ before comparing the two.
**The native water column is one surface and floor for the whole project.** Native files in
a project must share a vertical axis (a mix is refused at import) and an origin (this cannot
be checked from the files, so check it yourself). Samples outside the column are left out of
-IL-15, IL-16, IL-17 and GL-16, never clipped, and the run `README.md` counts them.
+IL-15, IL-16, IL-17, IL-18 and GL-16, never clipped, and the run `README.md` counts them.
**`flip` reverses the vertical axis only, which also mirrors the view from above.** For a
native file, headings and the sign of `left_right_turn_bias` change with it. The horizontal
diff --git a/pyproject.toml b/pyproject.toml
index e00dfa14..a180a903 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,6 +47,7 @@ dependencies = [
[project.optional-dependencies]
video = ["av>=12.0"] # pyav for frame extraction
zones = ["shapely>=2.0"] # polygon geometry
+xarray = ["xarray>=2023.1"] # interop/movement.py: a session as a movement-style Dataset
export = ["openpyxl>=3.1", "pyarrow>=15.0"]
docs = ["mkdocs-material>=9.5", "mkdocstrings[python]>=0.24"]
ui = ["PySide6>=6.6"] # desktop GUI (LGPL — safe for MIT distribution)
@@ -69,6 +70,7 @@ dev = [
"openpyxl>=3.1", # tests/test_exporters/test_excel.py imports it directly to
# verify workbook content; exporters/excel.py also needs it
# unconditionally to write .xlsx at all
+ "xarray>=2023.1", # tests/test_interop/* build a movement-style Dataset
"pyyaml>=6.0", # tests/test_issue_templates.py parses the GitHub issue forms
# in .github/ISSUE_TEMPLATE/. Test-only: nothing in the
# shipped package reads YAML.
diff --git a/tests/real_samples/test_detection_matrix.py b/tests/real_samples/test_detection_matrix.py
index 036ba935..7bfa700e 100644
--- a/tests/real_samples/test_detection_matrix.py
+++ b/tests/real_samples/test_detection_matrix.py
@@ -20,7 +20,7 @@
#: case id -> the reader that must claim it (and no other). None = nothing can read it yet.
EXPECTED: dict[str, str | None] = {
- "dlc_single_animal_h5": None, # .h5: arrives with the G-H5 decision
+ "dlc_single_animal_h5": "deeplabcut_h5",
"dlc_two_mice_csv": "deeplabcut",
"lightning_pose_eks_csv": "deeplabcut",
"sleap_named_tracks": "sleap_analysis",
diff --git a/tests/real_samples/test_dlc_h5.py b/tests/real_samples/test_dlc_h5.py
new file mode 100644
index 00000000..1cea1283
--- /dev/null
+++ b/tests/real_samples/test_dlc_h5.py
@@ -0,0 +1,42 @@
+"""The DeepLabCut ``.h5`` reader on the real EPM sample (D-050).
+
+Skips when the fixture is not cached and cannot be downloaded. Runs **without** PyTables: the
+reader's own h5py + restricted-unpickler path is what is under test, and ``tables`` is made
+unimportable for the read. Values are cross-checked against the pandas/PyTables oracle in
+``test_reader_contract.py`` (which needs ``tables`` and is skipped without it).
+"""
+
+import sys
+
+import pytest
+
+from tests.real_samples.fixtures import fetch
+from track2data import readers
+
+pytestmark = pytest.mark.contract
+
+DLC_EPM = "DLC_single-mouse_EPM.predictions.h5"
+BODYPARTS = [
+ "snout",
+ "left_ear",
+ "right_ear",
+ "centre",
+ "lateral_left",
+ "lateral_right",
+ "tailbase",
+ "tail_end",
+]
+
+
+def test_the_epm_real_sample_is_read_to_18485_frames(monkeypatch):
+ path = fetch(DLC_EPM)
+ monkeypatch.setitem(sys.modules, "tables", None) # any import of PyTables would now fail
+ session = readers.read_session(
+ path,
+ reader="deeplabcut_h5",
+ options={"fps": 30.0, "width_px": 1000, "height_px": 1000},
+ )
+ assert session.n_frames == 18485
+ assert session.keypoints is not None
+ assert list(session.keypoints.names) == BODYPARTS
+ assert session.reader == "deeplabcut_h5"
diff --git a/tests/support/dlc.py b/tests/support/dlc.py
index da384f83..a947e1e5 100644
--- a/tests/support/dlc.py
+++ b/tests/support/dlc.py
@@ -8,6 +8,7 @@
from __future__ import annotations
import csv
+import pickle
from pathlib import Path
import numpy as np
@@ -89,3 +90,121 @@ def random_pose(n_frames: int = 50, n_animals: int = 1, n_kp: int = 3, seed: int
offsets = np.zeros((1, 1, n_kp, 2))
offsets[..., 0] = 5.0 * np.arange(n_kp)
return centre + offsets
+
+
+def write_dlc_h5(
+ path: Path,
+ xy: np.ndarray,
+ likelihood: np.ndarray | None = None,
+ *,
+ bodyparts: list[str],
+ individuals: list[str] | None = None,
+ scorer: str = "DLC_resnet50_testshuffle1_1000",
+ frames: list[int] | None = None,
+ key: str = "df_with_missing",
+ label_pickle: bytes | None = None,
+ split_blocks: bool = False,
+) -> Path:
+ """Write ``xy`` (F, A, K, 2) in the layout pandas gives ``df.to_hdf(key=..., format="table")``.
+
+ Written with h5py alone (no PyTables), following what PyTables 3 writes: a group with the
+ plain-text ``pandas_type`` attribute, a ``table`` compound dataset of ``index`` and
+ ``values_block_N``, and the column labels as protocol-0 pickles in ``non_index_axes`` and each
+ block's ``values_block_N_kind``. ``label_pickle`` replaces the ``non_index_axes`` pickle (for
+ hostile-file tests); ``split_blocks`` stores the likelihood columns as a second block.
+ """
+ import h5py
+
+ def set_text(node, name: str, value: bytes, *, pickled: bool = False) -> None:
+ # PyTables writes plain text attributes as UTF-8 NULLTERM strings and pickled objects as
+ # ASCII ones, and tells them apart by that; pandas reads the file back only if both match.
+ tid = h5py.h5t.C_S1.copy()
+ tid.set_size(len(value))
+ tid.set_strpad(h5py.h5t.STR_NULLTERM)
+ tid.set_cset(h5py.h5t.CSET_ASCII if pickled else h5py.h5t.CSET_UTF8)
+ node.attrs.create(name, np.bytes_(value), dtype=tid)
+
+ n_frames, n_animals, n_kp, _ = xy.shape
+ if individuals is None and n_animals != 1:
+ raise ValueError("the single-animal layout holds one animal")
+ if likelihood is None:
+ likelihood = np.ones((n_frames, n_animals, n_kp))
+ names = individuals or [""]
+ labels: list[tuple[str, ...]] = []
+ columns: list[np.ndarray] = []
+ for a, animal in enumerate(names):
+ for k, bodypart in enumerate(bodyparts):
+ for coord in ("x", "y", "likelihood"):
+ labels.append(
+ (scorer, animal, bodypart, coord) if individuals else (scorer, bodypart, coord)
+ )
+ if coord == "likelihood":
+ columns.append(likelihood[:, a, k])
+ else:
+ columns.append(xy[:, a, k, 0 if coord == "x" else 1])
+ values = np.stack(columns, axis=1).astype(np.float64)
+ blocks: list[tuple[str, list[int]]] = [("values_block_0", list(range(len(labels))))]
+ if split_blocks:
+ is_lik = [i for i, label in enumerate(labels) if label[-1] == "likelihood"]
+ rest = [i for i in range(len(labels)) if i not in is_lik]
+ blocks = [("values_block_0", rest), ("values_block_1", is_lik)]
+ dtype = np.dtype([("index", " None:
+ """The pre-flight report (what `track2data validate` prints) carries the advisory."""
+ from track2data.api import Engine
+
+ session = _session_at("frag", 30.0, n_animals=3)
+ xy = session.raw_xy.copy()
+ xy[:, 1:] = np.nan # two of three slots never hold an animal
+ _patch_read_session(monkeypatch, {"frag": session.model_copy(update={"raw_xy": xy})})
+ manifest = _make_manifest(sessions=[_ref("frag")])
+
+ warnings = Engine(manifest).consistency_warnings()
+
+ assert any("track fragments" in w and "frag" in w for w in warnings)
+
+
def test_consistency_warnings_flags_mixed_frame_rates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
diff --git a/tests/test_cache/test_import_settings_cache.py b/tests/test_cache/test_import_settings_cache.py
index 5198fe6c..d069bd9d 100644
--- a/tests/test_cache/test_import_settings_cache.py
+++ b/tests/test_cache/test_import_settings_cache.py
@@ -37,6 +37,7 @@ def _manifest(
blob_calibration: bool = False,
overrides: dict[str, Path] | None = None,
extra_sessions: list[SessionRef] | None = None,
+ identity_free: bool | None = None,
) -> ProjectManifest:
now = datetime.now(tz=UTC)
return ProjectManifest(
@@ -44,7 +45,12 @@ def _manifest(
created_at=now,
updated_at=now,
sessions=[
- SessionRef(session_id=REF_ID, folder=folder, sha256="x"),
+ SessionRef(
+ session_id=REF_ID,
+ folder=folder,
+ sha256="x",
+ identity_free_override=identity_free,
+ ),
*(extra_sessions or []),
],
security=SecurityConfig(allow_pickle_trajectories=allow_pickle),
@@ -208,6 +214,16 @@ def test_each_import_setting_moves_the_key(self, folder, tmp_path) -> None:
}
assert len(keys) == 4
+ def test_the_identity_free_override_moves_the_key(self, folder, tmp_path) -> None:
+ """It decides whether gaps are bridged across tracking intervals in preprocessing."""
+ cache = tmp_path / "cache"
+ keys = {
+ self._key(_manifest(folder), cache),
+ self._key(_manifest(folder, identity_free=True), cache),
+ self._key(_manifest(folder, identity_free=False), cache),
+ }
+ assert len(keys) == 3
+
def test_a_worker_rebuilding_the_engine_from_the_manifest_json_gets_the_same_key(
self, folder, tmp_path
) -> None:
diff --git a/tests/test_camera_view_gating.py b/tests/test_camera_view_gating.py
index a8d6f1ee..69468ee9 100644
--- a/tests/test_camera_view_gating.py
+++ b/tests/test_camera_view_gating.py
@@ -532,3 +532,28 @@ def fake_run(plan, out_dir, exporters, progress, n_workers, cancel_check):
assert len(records) == 1
assert "2 sessions" in records[0].getMessage()
assert engine._skip_tally is None
+
+
+@pytest.mark.parametrize("metric_id", ["IL-3", "IL-14"])
+def test_il3_and_il14_run_on_a_fused_session_even_when_the_project_says_side(metric_id) -> None:
+ """A fused session is top-view, so the plan-view metrics keep running on it."""
+ fused = _with_depth(_make_psess(_make_session(n_frames=10, n_animals=2)))
+ engine = Engine(_manifest("side", [metric_id]))
+ assert engine.camera_view_for_psess(fused) == "top"
+ assert engine.skipped_metrics(False, fused.session, psess=fused) == {}
+
+
+@pytest.mark.parametrize("metric_id", ["IL-3", "IL-14"])
+@pytest.mark.parametrize("view", ["unknown", "top"])
+def test_il3_and_il14_are_not_skipped_for_unset_or_top_views(metric_id, view) -> None:
+ psess = _make_psess(_make_session(n_frames=10, n_animals=2))
+ engine = Engine(_manifest(view, [metric_id]))
+ assert metric_id not in engine.skipped_metrics(False, psess.session, psess=psess)
+
+
+@pytest.mark.parametrize("metric_id", ["IL-3", "IL-14"])
+def test_il3_and_il14_are_skipped_on_a_declared_side_view(metric_id) -> None:
+ psess = _make_psess(_make_session(n_frames=10, n_animals=2))
+ engine = Engine(_manifest("side", [metric_id]))
+ assert metric_id in engine.skipped_metrics(False, psess.session, psess=psess)
+ assert metric_id not in engine.compute_metrics(psess)
diff --git a/tests/test_cli/test_3d_run.py b/tests/test_cli/test_3d_run.py
index 2a0e8bb2..512f576a 100644
--- a/tests/test_cli/test_3d_run.py
+++ b/tests/test_cli/test_3d_run.py
@@ -56,3 +56,13 @@ def test_validate_3d_fails_when_no_pair_fuses(tmp_path: Path) -> None:
assert result.exit_code == 1
assert "[issue] no pair is ready to fuse: pair t1+s1: " in result.output
assert "ready to run" not in result.output
+
+
+def test_run_accepts_a_worker_count_and_rejects_zero(tmp_path: Path) -> None:
+ out = tmp_path / "out"
+ project = _project(tmp_path, side2_fps=10.0)
+ ok = CliRunner().invoke(cli, ["run", str(project), "-o", str(out), "--workers", "1"])
+ assert ok.exit_code == 0, ok.output
+ bad = CliRunner().invoke(cli, ["run", str(project), "-o", str(out), "--workers", "0"])
+ assert bad.exit_code == 2
+ assert "--workers" in bad.output
diff --git a/tests/test_cli/test_import_commands.py b/tests/test_cli/test_import_commands.py
index 9bdb69af..2b38225b 100644
--- a/tests/test_cli/test_import_commands.py
+++ b/tests/test_cli/test_import_commands.py
@@ -323,3 +323,43 @@ def test_a_ref_added_by_the_command_equals_the_one_the_draft_builds(
invoke("add", str(project), str(idtracker_root), "--yes")
expected: list[SessionRef] = ConfirmDraft(scan([idtracker_root])).to_session_refs()
assert read_manifest(project).sessions == expected
+
+
+class TestAddWithAReaderThatDidNotDetect:
+ def _folder_with_a_txt_table(self, tmp_path: Path) -> Path:
+ import numpy as np
+
+ from tests.support.dlc import random_pose, write_dlc_csv
+
+ root = tmp_path / "txt_root"
+ root.mkdir()
+ write_dlc_csv(
+ root / "trial1.txt",
+ random_pose(20, 1, 3),
+ np.ones((20, 1, 3)),
+ bodyparts=["a", "b", "c"],
+ )
+ return root
+
+ def test_without_reader_nothing_is_found(self, project: Path, tmp_path: Path) -> None:
+ out = invoke("add", str(project), str(self._folder_with_a_txt_table(tmp_path)), "--yes")
+ assert out.exit_code == 1
+
+ def test_cli_add_with_reader_retries_leniently_on_a_header_mismatch(
+ self, project: Path, tmp_path: Path
+ ) -> None:
+ root = self._folder_with_a_txt_table(tmp_path)
+ out = invoke(
+ "add", str(project), str(root), "--reader", "deeplabcut", "--yes",
+ "--option", "fps=30", "--option", "width_px=640", "--option", "height_px=480",
+ )
+ assert out.exit_code == 0, out.output
+ assert "looser rules" in out.output
+ assert [s.session_id for s in read_manifest(project).sessions] == ["trial1"]
+
+ def test_a_reader_that_cannot_read_them_still_fails(
+ self, project: Path, tmp_path: Path
+ ) -> None:
+ root = self._folder_with_a_txt_table(tmp_path)
+ out = invoke("add", str(project), str(root), "--reader", "sleap_analysis", "--yes")
+ assert out.exit_code == 1
diff --git a/tests/test_core/test_session_consistency.py b/tests/test_core/test_session_consistency.py
index 47a63a3c..f7dde5d6 100644
--- a/tests/test_core/test_session_consistency.py
+++ b/tests/test_core/test_session_consistency.py
@@ -82,6 +82,28 @@ def test_from_session_reads_the_session_not_the_manifest() -> None:
assert summary.is_calibrated
+def test_from_session_computes_the_nan_slot_fraction() -> None:
+ xy = np.zeros((10, 2, 2), dtype=np.float64)
+ xy[:5, 1] = np.nan # animal 1 is missing for 5 frames
+ xy[7, 0] = np.nan # animal 0 for one more: 6 of the 20 slots in all
+ session = Session(
+ session_id="frag",
+ folder=".",
+ reader="idtrackerai_v5",
+ video=VideoInfo(fps=30.0, n_frames=10, width_px=800, height_px=600),
+ n_animals=2,
+ trajectory_variant="with_gaps",
+ has_stable_identities=True,
+ raw_xy=xy,
+ )
+ summary = SessionSummary.from_session(session, calibration_mode="scalar")
+ assert summary.nan_slot_fraction == pytest.approx(6 / 20)
+
+
+def test_a_summary_built_without_the_field_has_none() -> None:
+ assert _summary().nan_slot_fraction is None
+
+
# ── heterogeneity_warnings ────────────────────────────────────────────────────
diff --git a/tests/test_core/test_session_identity.py b/tests/test_core/test_session_identity.py
new file mode 100644
index 00000000..761624b7
--- /dev/null
+++ b/tests/test_core/test_session_identity.py
@@ -0,0 +1,70 @@
+"""One rule for "the project already has this session", shared by the import dialog and the
+project store. The truth table is written out as data so the two code paths cannot drift."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from track2data.core.models import PanelRect, SessionRef
+from track2data.core.session_identity import same_session_part
+
+PANEL = PanelRect(x=0, y=0, width=100, height=50)
+OTHER_PANEL = PanelRect(x=100, y=0, width=100, height=50)
+FOLDER = Path("root") / "a"
+
+# (existing reader, existing panel, candidate reader, candidate panel) -> duplicate?
+# The candidate always sits in FOLDER, as does the existing entry; "same folder" is not the
+# variable here. The rule is not symmetric: only the *existing* entry's missing reader is a
+# wildcard.
+TABLE = [
+ (None, None, "a", None, True), # legacy entry, any reader
+ (None, None, None, None, True),
+ ("a", None, "a", None, True),
+ ("a", None, "b", None, False), # another reader reads the same files differently
+ ("a", None, None, None, False), # a candidate without a reader is not a wildcard
+ ("a", PANEL, "a", None, False), # split into panels: the whole frame is offered again
+ ("a", None, "a", PANEL, False),
+ ("a", PANEL, "a", PANEL, True),
+ ("a", PANEL, "a", OTHER_PANEL, False),
+ (None, PANEL, "a", None, False),
+]
+
+
+def _ref(reader: str | None, panel: PanelRect | None, folder: Path = FOLDER) -> SessionRef:
+ return SessionRef(session_id="s", folder=folder, sha256="", reader=reader, panel=panel)
+
+
+@pytest.mark.parametrize(("e_reader", "e_panel", "c_reader", "c_panel", "duplicate"), TABLE)
+def test_same_session_part_truth_table(e_reader, e_panel, c_reader, c_panel, duplicate) -> None:
+ assert same_session_part(_ref(c_reader, c_panel), _ref(e_reader, e_panel)) is duplicate
+
+
+def test_another_folder_is_never_the_same_session() -> None:
+ assert not same_session_part(_ref("a", None), _ref("a", None, Path("root") / "b"))
+
+
+@pytest.mark.parametrize(("e_reader", "e_panel", "c_reader", "c_panel", "duplicate"), TABLE)
+def test_the_project_store_agrees(e_reader, e_panel, c_reader, c_panel, duplicate) -> None:
+ from ui.store.project_store import _is_duplicate
+
+ assert _is_duplicate(_ref(c_reader, c_panel), [_ref(e_reader, e_panel)]) is duplicate
+
+
+@pytest.mark.parametrize(("e_reader", "e_panel", "c_reader", "c_panel", "duplicate"), TABLE)
+def test_the_import_dialog_agrees_where_it_can_express_the_case(
+ e_reader, e_panel, c_reader, c_panel, duplicate
+) -> None:
+ """The dialog's candidates are whole-frame sessions of the draft's reader."""
+ from tests.test_readers.test_confirm import detection, result
+ from track2data.readers.confirm import ConfirmDraft
+
+ if c_reader is None or c_panel is not None:
+ pytest.skip("a dialog candidate always has a reader and no panel")
+ draft = ConfirmDraft(
+ result((detection(c_reader, ids=("a",)),)), existing=[_ref(e_reader, e_panel)]
+ )
+ (row,) = draft.rows
+ assert row.already_added is duplicate
+ assert row.included is (not duplicate)
diff --git a/tests/test_exporters/test_merge.py b/tests/test_exporters/test_merge.py
index 98900a6f..b2a05229 100644
--- a/tests/test_exporters/test_merge.py
+++ b/tests/test_exporters/test_merge.py
@@ -119,3 +119,43 @@ def test_exports_with_metadata_and_two_metrics_have_no_suffixed_columns(
assert not [c for c in sheet.columns if str(c).endswith(("_x", "_y"))]
assert len(sheet) == 2
assert sheet["path_length_px"].notna().all() and sheet["mean_speed_px_s"].notna().all()
+
+
+class TestBinnedAfterWholeSession:
+ """A whole-session frame (NaN bins) merged with a binned one must keep the bin boundaries."""
+
+ def _frames(self) -> dict[str, pd.DataFrame]:
+ nan = np.nan
+ return {
+ "whole": pd.DataFrame(
+ {"session_id": ["s"], "individual_id": [0], "bin_index": [nan],
+ "bin_start_s": [nan], "bin_end_s": [nan], "total": [9.0]}
+ ),
+ "binned": pd.DataFrame(
+ {"session_id": ["s", "s"], "individual_id": [0, 0], "bin_index": [0, 1],
+ "bin_start_s": [0.0, 60.0], "bin_end_s": [60.0, 120.0], "path": [1.0, 2.0]}
+ ),
+ }
+
+ def test_the_bin_boundaries_survive_in_either_order(self) -> None:
+ frames = self._frames()
+ for order in (list(frames), list(reversed(list(frames)))):
+ out = merge_metric_frames({k: frames[k] for k in order})
+ binned = out[out["bin_index"].notna()].sort_values("bin_index")
+ assert list(binned["bin_start_s"]) == [0.0, 60.0]
+ assert list(binned["bin_end_s"]) == [60.0, 120.0]
+ assert list(binned["path"]) == [1.0, 2.0]
+
+ def test_a_row_only_the_second_frame_has_is_filled_from_it(self) -> None:
+ frames = {
+ "a": pd.DataFrame(
+ {"session_id": ["s"], "individual_id": [0], "treatment": ["ctrl"], "x": [1.0]}
+ ),
+ "b": pd.DataFrame(
+ {"session_id": ["s", "s"], "individual_id": [0, 1], "treatment": "ctrl",
+ "y": [2.0, 3.0]}
+ ),
+ }
+ out = merge_metric_frames(frames)
+ assert list(out["treatment"]) == ["ctrl", "ctrl"]
+ assert "treatment_x" not in out.columns
diff --git a/tests/test_exporters/test_schema.py b/tests/test_exporters/test_schema.py
index 93ad614b..6ccf9736 100644
--- a/tests/test_exporters/test_schema.py
+++ b/tests/test_exporters/test_schema.py
@@ -265,3 +265,20 @@ def test_empty_and_missing_metric_frames_are_skipped() -> None:
out = long_table({"IL-1": _individual_frame(), "EMPTY": empty, "NONE": None})
assert set(out["metric_id"]) == {"IL-1"}
+
+
+def test_long_table_keeps_the_time_bin_of_each_value() -> None:
+ binned = pd.DataFrame(
+ {"session_id": ["s", "s"], "individual_id": [0, 0], "bin_index": [0, 1],
+ "bin_start_s": [0.0, 60.0], "bin_end_s": [60.0, 120.0], "path_length_px": [5.0, 7.0]}
+ )
+ out = long_table({"IL-1": binned})
+ assert set(out["column"]) == {"path_length_px"} # the bin columns are not measurements
+ assert list(out["bin_index"]) == [0, 1]
+ assert list(out["bin_start_s"]) == [0.0, 60.0]
+ assert list(out["value"]) == [5.0, 7.0]
+
+
+def test_an_unbinned_long_table_has_no_bin_columns() -> None:
+ out = long_table({"IL-1": _individual_frame()})
+ assert not {"bin_index", "bin_start_s", "bin_end_s"} & set(out.columns)
diff --git a/tests/test_integration/test_3d_metrics.py b/tests/test_integration/test_3d_metrics.py
index 7e28ce32..04c8af7f 100644
--- a/tests/test_integration/test_3d_metrics.py
+++ b/tests/test_integration/test_3d_metrics.py
@@ -1,4 +1,4 @@
-"""The 3-D metrics (IL-16, IL-17, GL-16) end to end on the physical test scene: exported values
+"""The 3-D metrics (IL-16/17/18, GL-16/18/19) end to end on the physical test scene: exported values
against the scene's known 3-D path, the skip under body-length calibration, and a 2-D control."""
from __future__ import annotations
@@ -16,7 +16,11 @@
from track2data.api import Engine
from track2data.core.models import CalibrationConfig, MetricSelection, ProjectMode
-IDS = {"individual": ["IL-1", "IL-2", "IL-16", "IL-17"], "group": ["GL-1", "GL-16"]}
+IDS = {
+ "individual": ["IL-1", "IL-2", "IL-16", "IL-17", "IL-18"],
+ "group": ["GL-1", "GL-16", "GL-18", "GL-19"],
+}
+THREE_D = ("IL-16", "IL-17", "IL-18", "GL-16", "GL-18", "GL-19")
EXPORTERS = ["csv_long", "readme"]
SKIP_MODE = "needs a cm scale for the top view (use scalar or session calibration)"
# The scene: every fish moves 1 px/frame in x (0.1 cm at 10 px/cm) at a constant depth, so the
@@ -77,10 +81,25 @@ def test_exported_values_match_the_scene(scalar_run: Path) -> None:
assert _value(long, "GL-16", "n_skipped_frames_3d") == 0
+def test_the_new_3d_metrics_match_the_scene(scalar_run: Path) -> None:
+ long = _long(scalar_run)
+ for fish in range(3):
+ # the scene has no vertical movement
+ assert _value(long, "IL-18", "mean_vertical_speed_cm_s", fish) == pytest.approx(0, abs=1e-6)
+ assert _value(long, "IL-18", "max_vertical_speed_cm_s", fish) == pytest.approx(0, abs=1e-6)
+ nnd = _value(long, "GL-16", "mean_nnd_3d_cm")
+ iid = _value(long, "GL-18", "mean_iid_3d_cm")
+ assert iid >= nnd - 1e-9 # the mean over all pairs is at least the mean nearest neighbour
+ assert _value(long, "GL-18", "n_skipped_frames_3d") == 0
+ assert _value(long, "GL-18", "frac_frames_used_3d") == pytest.approx(1.0)
+ # cohesion defaults to the NND source: 1 / GL-16's mean
+ np.testing.assert_allclose(_value(long, "GL-19", "cohesion_index_3d"), 1.0 / nnd, rtol=1e-9)
+
+
def test_both_units_have_the_3d_tables(scalar_run: Path) -> None:
for unit in ("t1+s1", "t2+s2"):
ids = set(_long(scalar_run, unit)["metric_id"])
- assert {"IL-16", "IL-17", "GL-16"} <= ids
+ assert set(THREE_D) <= ids
def test_bodylength_calibration_skips_the_3d_metrics_with_the_reason(tmp_path: Path) -> None:
@@ -90,11 +109,11 @@ def test_bodylength_calibration_skips_the_3d_metrics_with_the_reason(tmp_path: P
out = tmp_path / "out"
Engine(manifest).run(out, exporters=EXPORTERS)
ids = set(_long(out)["metric_id"])
- assert ids.isdisjoint({"IL-16", "IL-17", "GL-16"})
+ assert ids.isdisjoint(set(THREE_D))
assert {"IL-1", "IL-2", "GL-1"} <= ids
readme = (out / "t1+s1" / "README.md").read_text(encoding="utf-8")
assert "## Metrics skipped" in readme
- for mid in ("IL-16", "IL-17", "GL-16"):
+ for mid in THREE_D:
assert mid in readme
assert SKIP_MODE in readme
@@ -108,7 +127,7 @@ def test_a_2d_project_skips_the_3d_metrics_and_is_unchanged(tmp_path: Path) -> N
assert (out / "sessions.csv").read_text(encoding="utf-8").splitlines()[0] == OLD_SESSIONS_HEADER
for unit in ("t1", "s1", "t2", "s2", "lone"):
ids = set(_long(out, unit)["metric_id"])
- assert ids.isdisjoint({"IL-16", "IL-17", "GL-16"})
+ assert ids.isdisjoint(set(THREE_D))
assert {"IL-1", "IL-2", "GL-1"} <= ids
readme = (out / unit / "README.md").read_text(encoding="utf-8")
assert "needs a fused 3-D session" in readme
diff --git a/tests/test_integration/test_end_to_end.py b/tests/test_integration/test_end_to_end.py
index e8e34de1..c6084a6e 100644
--- a/tests/test_integration/test_end_to_end.py
+++ b/tests/test_integration/test_end_to_end.py
@@ -309,15 +309,17 @@ def _ids_for(level: str) -> list[str]:
zone=_ids_for("zone"),
diagnostic=[],
),
- # IL-15 is offered only for a declared side view.
- "scene": SceneConfig(camera_view="side"),
}
)
- engine = Engine(manifest)
-
- session = engine.import_session(tiny_real_session)
- psess = engine.preprocess(session)
- results = engine.compute_metrics(psess)
+ # IL-15 is offered only for a declared side view and IL-3/IL-14 only for a top-down (or
+ # unset) one, so every metric is covered by running both views and taking the union.
+ results: dict[str, pd.DataFrame] = {}
+ for view in ("top", "side"):
+ view_manifest = manifest.model_copy(update={"scene": SceneConfig(camera_view=view)})
+ engine = Engine(view_manifest)
+ session = engine.import_session(tiny_real_session)
+ psess = engine.preprocess(session)
+ results.update(engine.compute_metrics(psess))
# A 3-D metric (needs a fused session and a cm scale) is never offered in a 2-D project.
expected = [mid for mid in all_ids if not metrics_registry.get(mid).requires_depth_scale]
diff --git a/tests/test_interop/__init__.py b/tests/test_interop/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_interop/test_movement.py b/tests/test_interop/test_movement.py
new file mode 100644
index 00000000..010c1650
--- /dev/null
+++ b/tests/test_interop/test_movement.py
@@ -0,0 +1,153 @@
+"""``to_movement_dataset``: a preprocessed session as a movement-style xarray Dataset."""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from track2data.core.errors import ImportError_
+from track2data.core.models import (
+ KinematicsArrays,
+ PreprocessedSession,
+ PreprocessReport,
+ Session,
+ VideoInfo,
+)
+from track2data.interop.movement import to_movement_dataset
+
+FPS = 25.0
+
+
+def make_psess(
+ n_frames: int = 10,
+ n_animals: int = 2,
+ *,
+ labels: list[str] | None = None,
+ intervals: list[tuple[int, int]] | None = None,
+ reader: str = "idtrackerai",
+ probabilities: np.ndarray | None = None,
+ z: np.ndarray | None = None,
+ native_unit: str | None = None,
+ **extra,
+) -> PreprocessedSession:
+ xy = np.random.default_rng(0).uniform(0, 100, size=(n_frames, n_animals, 2))
+ session = Session(
+ session_id="s",
+ folder=Path("/tmp/s"),
+ reader=reader,
+ video=VideoInfo(fps=FPS, n_frames=n_frames, width_px=640, height_px=480),
+ n_animals=n_animals,
+ trajectory_variant="wo_gaps",
+ has_stable_identities=True,
+ raw_xy=xy,
+ identities_labels=labels,
+ tracking_intervals=intervals,
+ id_probabilities=probabilities,
+ has_pixel_frame=native_unit is None,
+ coordinate_unit=native_unit,
+ )
+ speed = np.ones((n_frames, n_animals))
+ kine = KinematicsArrays(
+ speed_px_s=speed, accel_px_s2=np.zeros_like(speed), heading_rad=np.zeros_like(speed)
+ )
+ return PreprocessedSession(
+ session=session, xy=xy, kinematics=kine, report=PreprocessReport(), z=z, **extra
+ )
+
+
+def test_a_missing_xarray_raises_a_coded_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setitem(sys.modules, "xarray", None)
+ with pytest.raises(ImportError_) as caught:
+ to_movement_dataset(make_psess())
+ assert caught.value.code == "XARRAY_MISSING"
+ assert "track2data[xarray]" in caught.value.remediation
+
+
+needs_xarray = pytest.mark.skipif(
+ importlib.util.find_spec("xarray") is None, reason="needs the optional xarray package"
+)
+
+
+@needs_xarray
+def test_dims_and_shapes_for_a_two_animal_session() -> None:
+ psess = make_psess(10, 2, labels=["a", "b"])
+ ds = to_movement_dataset(psess)
+ assert ds["position"].dims == ("time", "individuals", "keypoints", "space")
+ assert ds["position"].shape == (10, 2, 1, 2)
+ assert list(ds["individuals"].values) == ["a", "b"]
+ assert list(ds["keypoints"].values) == ["centroid"]
+ assert list(ds["space"].values) == ["x", "y"]
+ np.testing.assert_allclose(ds["position"].values[:, :, 0, :], psess.xy)
+ np.testing.assert_allclose(ds["time"].values, np.arange(10) / FPS)
+
+
+@needs_xarray
+def test_unlabelled_animals_are_numbered() -> None:
+ ds = to_movement_dataset(make_psess(5, 3))
+ assert list(ds["individuals"].values) == ["0", "1", "2"]
+
+
+@needs_xarray
+def test_time_follows_the_timeline_across_a_tracking_gap() -> None:
+ psess = make_psess(10, 1, intervals=[(0, 5), (10, 15)])
+ ds = to_movement_dataset(psess)
+ frames = np.array([0, 1, 2, 3, 4, 10, 11, 12, 13, 14])
+ np.testing.assert_allclose(ds["time"].values, frames / FPS)
+ assert np.all(np.diff(ds["time"].values) > 0)
+
+
+@needs_xarray
+def test_separator_rows_are_dropped_by_default_and_kept_on_request() -> None:
+ separator = np.zeros(6, dtype=bool)
+ separator[3] = True
+ psess = make_psess(
+ 6,
+ 1,
+ frame_index=np.array([0, 1, 2, 3, 10, 11]),
+ tracked_mask=~separator,
+ separator_mask=separator,
+ )
+ psess.xy[3] = np.nan
+ dropped = to_movement_dataset(psess)
+ assert dropped["position"].shape[0] == 5
+ assert not np.isnan(dropped["position"].values).any()
+ np.testing.assert_allclose(dropped["time"].values, np.array([0, 1, 2, 10, 11]) / FPS)
+ kept = to_movement_dataset(psess, include_separator_rows=True)
+ assert kept["position"].shape[0] == 6
+ assert np.isnan(kept["position"].values[3]).all()
+
+
+@needs_xarray
+def test_identity_confidence_is_not_called_confidence() -> None:
+ probabilities = np.full((10, 2), 0.9)
+ ds = to_movement_dataset(make_psess(10, 2, probabilities=probabilities))
+ assert "confidence" not in ds
+ assert ds["identity_confidence"].dims == ("time", "individuals")
+ np.testing.assert_allclose(ds["identity_confidence"].values, 0.9)
+ assert "identity_confidence" not in to_movement_dataset(make_psess(10, 2))
+
+
+@needs_xarray
+def test_a_native_3d_session_gets_a_z_space_coordinate() -> None:
+ z = np.linspace(0, 9, 10)[:, None]
+ ds = to_movement_dataset(make_psess(10, 1, z=z, native_unit="cm"))
+ assert list(ds["space"].values) == ["x", "y", "z"]
+ assert ds["position"].shape == (10, 1, 1, 3)
+ np.testing.assert_allclose(ds["position"].values[:, 0, 0, 2], z[:, 0])
+ assert ds.attrs["length_unit"] == "cm"
+
+
+@needs_xarray
+def test_attrs_carry_source_software_and_unit() -> None:
+ ds = to_movement_dataset(make_psess())
+ assert ds.attrs["fps"] == FPS
+ assert ds.attrs["source_software"] == "idtracker.ai"
+ assert ds.attrs["reader_verification"] in ("real_sample", "synthetic_only")
+ assert ds.attrs["length_unit"] == "px"
+ assert np.isnan(ds.attrs["px_per_cm"])
+ calibrated = to_movement_dataset(make_psess(px_per_cm=12.5))
+ assert calibrated.attrs["px_per_cm"] == 12.5
diff --git a/tests/test_metadata/test_per_animal.py b/tests/test_metadata/test_per_animal.py
index 36f9bd1e..656b92c4 100644
--- a/tests/test_metadata/test_per_animal.py
+++ b/tests/test_metadata/test_per_animal.py
@@ -140,3 +140,29 @@ def test_individual_match_defaults_to_label_and_validates() -> None:
assert MappingRule(individual_match="index").individual_match == "index"
with pytest.raises(ValueError):
MappingRule(individual_match="position") # type: ignore[arg-type]
+
+
+# ── numeric session keys ─────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize(
+ "ids",
+ [[1, 2, 3], [1.0, 2.0, 3.0], [1.0, 2.0, float("nan")]],
+ ids=["int", "float-whole", "float-with-blank"],
+)
+def test_numeric_session_ids_match_the_text_session_ids_of_folders(ids) -> None:
+ """A column of trial numbers is read as numbers; sessions from folders '1','2','3' are text."""
+ df = pd.DataFrame({"session_id": ids, "treatment": ["a", "b", "c"]})
+ rule = MappingRule(rules={"session_id": "session_id", "treatment": "treatment"})
+ res = match(["1", "2"], df, rule)
+ assert res.unmatched_sessions == []
+ assert res.matched["1"]["treatment"] == "a"
+ assert res.matched["2"]["treatment"] == "b"
+
+
+def test_numeric_composite_key_columns_match_too() -> None:
+ df = pd.DataFrame({"day": [1, 2], "tank": [7, 7], "treatment": ["a", "b"]})
+ rule = MappingRule(rules={"treatment": "treatment"}, join_keys=["day", "tank"])
+ res = match(["1_7", "2_7"], df, rule)
+ assert res.unmatched_sessions == []
+ assert res.matched["2_7"]["treatment"] == "b"
diff --git a/tests/test_metadata/test_schema.py b/tests/test_metadata/test_schema.py
index d6ff008d..66e4680e 100644
--- a/tests/test_metadata/test_schema.py
+++ b/tests/test_metadata/test_schema.py
@@ -246,3 +246,121 @@ def test_join_key_column_absent_from_dataframe() -> None:
rule = MappingRule(join_keys=["video_id"])
result = match(["vid_42"], df, rule)
assert "vid_42" in result.unmatched_sessions
+
+
+# ── Excel sheets ─────────────────────────────────────────────────────────────
+
+
+def _workbook(path: Path, sheets: dict[str, list[list[str]]]) -> Path:
+ openpyxl = pytest.importorskip("openpyxl")
+ wb = openpyxl.Workbook()
+ wb.remove(wb.active)
+ for name, rows in sheets.items():
+ ws = wb.create_sheet(name)
+ for row in rows:
+ ws.append(row)
+ wb.save(path)
+ return path
+
+
+TWO_SHEETS = {
+ "Notes": [["note"], ["read me"]],
+ "Trials": [["Session ID", "Treatment"], ["s1", "ctrl"], ["s2", "drug"]],
+}
+
+
+def test_load_xlsx_defaults_to_the_first_sheet(tmp_path: Path) -> None:
+ from track2data.metadata.loader import load
+
+ df = load(_workbook(tmp_path / "m.xlsx", TWO_SHEETS))
+ assert list(df.columns) == ["note"]
+
+
+def test_load_xlsx_reads_a_named_sheet(tmp_path: Path) -> None:
+ from track2data.metadata.loader import load
+
+ df = load(_workbook(tmp_path / "m.xlsx", TWO_SHEETS), sheet="Trials")
+ assert list(df.columns) == ["session id", "treatment"]
+ assert df["treatment"].tolist() == ["ctrl", "drug"]
+
+
+def test_an_unknown_sheet_name_lists_the_available_ones(tmp_path: Path) -> None:
+ from track2data.metadata.loader import load
+
+ path = _workbook(tmp_path / "m.xlsx", TWO_SHEETS)
+ with pytest.raises(ValueError, match="Notes, Trials"):
+ load(path, sheet="Missing")
+
+
+def test_list_sheets_of_a_csv_is_empty(tmp_path: Path) -> None:
+ from track2data.metadata.loader import list_sheets
+
+ csv = tmp_path / "m.csv"
+ csv.write_text("a,b\n1,2\n")
+ assert list_sheets(csv) == []
+ assert list_sheets(_workbook(tmp_path / "m.xlsx", TWO_SHEETS)) == ["Notes", "Trials"]
+
+
+def test_an_old_xls_file_is_refused_with_a_clear_message(tmp_path: Path) -> None:
+ from track2data.metadata.loader import list_sheets, load
+
+ old = tmp_path / "old.xls"
+ old.write_bytes(b"\xd0\xcf\x11\xe0")
+ for call in (load, list_sheets):
+ with pytest.raises(ValueError, match=r"\.xlsx"):
+ call(old)
+
+
+def test_preview_keeps_the_stored_header_case(tmp_path: Path) -> None:
+ from track2data.metadata.loader import preview
+
+ headers, rows = preview(_workbook(tmp_path / "m.xlsx", TWO_SHEETS), "Trials")
+ assert headers == ["Session ID", "Treatment"]
+ assert rows == [["s1", "ctrl"], ["s2", "drug"]]
+
+
+# ── MetadataSource.sheet and the project hash ────────────────────────────────
+
+
+def _manifest(**source):
+ from datetime import UTC, datetime
+
+ from track2data.core.models import MetadataSource, ProjectManifest
+
+ t = datetime(2026, 1, 1, tzinfo=UTC)
+ return ProjectManifest(
+ project_name="p",
+ created_at=t,
+ updated_at=t,
+ metadata_source=MetadataSource(path=Path("meta.csv"), sha256="ab" * 32, **source),
+ )
+
+
+def test_a_manifest_without_a_sheet_keeps_its_hash_and_json() -> None:
+ manifest = _manifest()
+ assert "sheet" not in manifest.model_dump()["metadata_source"]
+ assert (
+ '"metadata_source":{"path":"meta.csv","sha256":"' + "ab" * 32 + '"}'
+ in manifest.model_dump_json()
+ )
+ # Hash pinned on the dump a manifest without the field produced before it existed.
+ import hashlib
+ import json
+
+ data = manifest.model_dump(exclude={"created_at", "updated_at", "run_log_path"})
+ legacy = json.dumps(data, sort_keys=True, default=str)
+ assert '"sheet"' not in legacy
+ assert manifest.project_hash() == hashlib.sha256(legacy.encode()).hexdigest()[:16]
+
+
+def test_a_chosen_sheet_is_saved_and_changes_the_hash() -> None:
+ from track2data.core.models import ProjectManifest
+
+ plain, chosen = _manifest(), _manifest(sheet="Trials")
+ assert chosen.project_hash() != plain.project_hash()
+ assert '"sheet":"Trials"' in chosen.model_dump_json()
+ again = ProjectManifest.model_validate_json(chosen.model_dump_json())
+ assert again.metadata_source.sheet == "Trials"
+ assert again.project_hash() == chosen.project_hash()
+ old = ProjectManifest.model_validate_json(plain.model_dump_json())
+ assert old.metadata_source.sheet is None
diff --git a/tests/test_metrics/test_availability.py b/tests/test_metrics/test_availability.py
index 4e3e0470..d4afcec9 100644
--- a/tests/test_metrics/test_availability.py
+++ b/tests/test_metrics/test_availability.py
@@ -177,7 +177,7 @@ def test_a_native_project_has_3d_data_and_asks_for_the_water_column() -> None:
assert (view, has_depth) == ("top", False) # the declared view is irrelevant
scale = manifest_depth_scale(native)
assert scale["dimension"] == "3d" and scale["has_depth"] is True
- for mid in ("IL-16", "IL-17", "IL-1"):
+ for mid in ("IL-16", "IL-17", "IL-18", "IL-1"):
assert unavailable_reason(get(mid), view, has_depth=has_depth, depth_scale=scale) is None
assert (
unavailable_reason(get("IL-15"), view, has_depth=has_depth, depth_scale=scale)
@@ -199,3 +199,29 @@ def test_a_native_project_has_3d_data_and_asks_for_the_water_column() -> None:
)
is None
)
+
+
+# ── IL-3 and IL-14 are plan-view metrics ─────────────────────────────────────
+
+
+@pytest.mark.parametrize("metric_id", ["IL-3", "IL-14"])
+def test_il3_and_il14_declare_top_and_unknown_views(metric_id: str) -> None:
+ import track2data.metrics as registry
+
+ assert registry.get(metric_id).valid_camera_views == frozenset({"top", "unknown"})
+
+
+@pytest.mark.parametrize("metric_id", ["IL-3", "IL-14"])
+def test_il3_and_il14_are_unavailable_on_a_declared_side_view(metric_id: str) -> None:
+ import track2data.metrics as registry
+
+ reason = view_unavailable_reason(registry.get(metric_id), "side")
+ assert reason is not None and "top-down" in reason
+
+
+@pytest.mark.parametrize("metric_id", ["IL-3", "IL-14"])
+@pytest.mark.parametrize("view", ["top", "unknown"])
+def test_il3_and_il14_stay_available_for_top_and_unset_views(metric_id: str, view: str) -> None:
+ import track2data.metrics as registry
+
+ assert view_unavailable_reason(registry.get(metric_id), view) is None
diff --git a/tests/test_metrics/test_cohesion_3d.py b/tests/test_metrics/test_cohesion_3d.py
new file mode 100644
index 00000000..4e2ec75c
--- /dev/null
+++ b/tests/test_metrics/test_cohesion_3d.py
@@ -0,0 +1,65 @@
+"""GL-19, 3-D group cohesion: GL-6 in (X, Y, Z) cm, from NND or IID."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from tests.test_metrics.test_iid_3d import _fused, _three_fish
+from track2data.metrics import get
+from track2data.metrics.group import GroupCohesion, GroupCohesion3D
+
+
+def _cohesion(psess, source: str | None = None) -> float:
+ cfg = None if source is None else {"cohesion_source": source}
+ return float(GroupCohesion3D().compute(psess, cfg).iloc[0]["cohesion_index_3d"])
+
+
+def test_is_registered_with_the_agreed_identity() -> None:
+ cls = get("GL-19")
+ assert cls is GroupCohesion3D
+ assert (cls.name, cls.label, cls.level) == ("group_cohesion_3d", "3-D Group Cohesion", "group")
+ assert cls.requires_identity is False and cls.requires_depth_scale is True
+ assert cls.output_columns == ["session_id", "metric_id", "cohesion_index_3d"]
+ assert [p.name for p in cls.parameters] == ["cohesion_source"]
+
+
+def test_cohesion_is_the_reciprocal_of_the_mean_distance_for_both_sources() -> None:
+ psess = _fused(_three_fish())
+ # NND: A->B 5, B->A 5, C->A 12, mean 22/3; IID: (5 + 12 + 13) / 3 = 10
+ assert _cohesion(psess) == pytest.approx(3 / 22)
+ assert _cohesion(psess, "nnd") == pytest.approx(3 / 22)
+ assert _cohesion(psess, "iid") == pytest.approx(1 / 10)
+
+
+def test_an_unknown_cohesion_source_is_refused() -> None:
+ with pytest.raises(ValueError, match="cohesion_source"):
+ _cohesion(_fused(_three_fish()), "IID")
+
+
+def test_vertical_separation_lowers_the_3d_index_below_gl6() -> None:
+ pos = np.zeros((5, 2, 3))
+ pos[:, 1, 0] = 4.0
+ pos[:, 1, 2] = 3.0 # 5 cm apart in 3-D, 4 cm in the plane
+ psess = _fused(pos)
+ assert _cohesion(psess) == pytest.approx(1 / 5)
+ # GL-6 is per pixel (4 cm = 32 px), a different unit, but sees only the plane
+ assert GroupCohesion().compute(psess).iloc[0]["cohesion_index"] == pytest.approx(1 / 32)
+
+
+def test_zero_distance_and_single_animal_are_nan() -> None:
+ assert np.isnan(_cohesion(_fused(np.zeros((4, 2, 3)))))
+ assert np.isnan(_cohesion(_fused(np.zeros((4, 1, 3)))))
+
+
+def test_frames_with_a_nan_are_skipped() -> None:
+ psess = _fused(_three_fish(5))
+ psess.depth[1, 2] = np.nan
+ assert _cohesion(psess) == pytest.approx(3 / 22)
+
+
+@pytest.mark.parametrize("missing", ["depth", "px_per_cm", "depth_height_cm"])
+def test_without_depth_or_scale_it_is_nan(missing: str) -> None:
+ psess = _fused(_three_fish())
+ setattr(psess, missing, None)
+ assert np.isnan(_cohesion(psess))
diff --git a/tests/test_metrics/test_identity_classification.py b/tests/test_metrics/test_identity_classification.py
index a77d205a..d77ac99c 100644
--- a/tests/test_metrics/test_identity_classification.py
+++ b/tests/test_metrics/test_identity_classification.py
@@ -40,9 +40,9 @@
# Every metric that must not run on an identity-free session.
IDENTITY_REQUIRING = {
- # All fifteen individual metrics: each is a per-animal time series.
+ # All sixteen individual metrics: each is a per-animal time series.
"IL-1", "IL-2", "IL-3", "IL-4", "IL-5", "IL-6",
- "IL-7", "IL-8", "IL-9", "IL-10", "IL-11", "IL-14", "IL-15", "IL-16", "IL-17",
+ "IL-7", "IL-8", "IL-9", "IL-10", "IL-11", "IL-14", "IL-15", "IL-16", "IL-17", "IL-18",
# Group metrics built on per-individual headings.
"GL-3", # polarisation: mean unit heading vector
"GL-8", # rotational order: heading relative to the group centroid
diff --git a/tests/test_metrics/test_iid_3d.py b/tests/test_metrics/test_iid_3d.py
new file mode 100644
index 00000000..4313fd87
--- /dev/null
+++ b/tests/test_metrics/test_iid_3d.py
@@ -0,0 +1,146 @@
+"""GL-18, 3-D inter-individual distance: GL-2 in (X, Y, Z) cm on a fused session."""
+
+from __future__ import annotations
+
+import warnings
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from tests.test_api import _make_psess, _make_session
+from track2data.metrics import get
+from track2data.metrics.group import (
+ InterIndividualDistance,
+ InterIndividualDistance3D,
+ NearestNeighbourDistance3D,
+)
+
+PX_PER_CM = 8.0
+HEIGHT_CM = 200.0
+COLUMNS = [
+ "session_id",
+ "metric_id",
+ "mean_iid_3d_cm",
+ "median_iid_3d_cm",
+ "mean_iid_3d_bl",
+ "n_skipped_frames_3d",
+ "frac_frames_used_3d",
+]
+STATS = COLUMNS[2:5]
+
+
+def _fused(pos_cm: np.ndarray, *, body_length_cm: float | None = None):
+ n_frames, n_animals = pos_cm.shape[:2]
+ psess = _make_psess(_make_session(n_frames=n_frames, n_animals=n_animals))
+ if body_length_cm is not None:
+ psess.body_length_px = np.full(n_animals, body_length_cm * PX_PER_CM)
+ psess.xy = pos_cm[..., :2] * PX_PER_CM
+ psess.depth = pos_cm[..., 2] / HEIGHT_CM
+ psess.px_per_cm = PX_PER_CM
+ psess.depth_height_cm = HEIGHT_CM
+ return psess
+
+
+def _run(psess) -> pd.DataFrame:
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", RuntimeWarning)
+ return InterIndividualDistance3D().compute(psess)
+
+
+def _three_fish(n_frames: int = 4) -> np.ndarray:
+ # d(A,B)=5, d(A,C)=12, d(B,C)=13
+ pts = np.array([[0.0, 0.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 12.0]])
+ return np.broadcast_to(pts, (n_frames, 3, 3)).copy()
+
+
+def test_is_registered_with_the_agreed_identity() -> None:
+ cls = get("GL-18")
+ assert cls is InterIndividualDistance3D
+ assert (cls.name, cls.label, cls.level) == (
+ "inter_individual_distance_3d",
+ "3-D Inter-Individual Distance",
+ "group",
+ )
+ assert cls.requires_identity is False
+ assert cls.requires_depth_scale is True
+ assert cls.priority == "optional"
+ assert cls.output_columns == COLUMNS
+
+
+def test_two_fish_at_known_3d_separation() -> None:
+ pos = np.zeros((5, 2, 3))
+ pos[:, 1] = [3.0, 4.0, 12.0] # 13 cm apart
+ row = _run(_fused(pos, body_length_cm=2.0)).iloc[0]
+ assert row["metric_id"] == "GL-18"
+ assert row["mean_iid_3d_cm"] == pytest.approx(13.0)
+ assert row["median_iid_3d_cm"] == pytest.approx(13.0)
+ assert row["mean_iid_3d_bl"] == pytest.approx(6.5)
+ assert row["n_skipped_frames_3d"] == 0
+ assert row["frac_frames_used_3d"] == pytest.approx(1.0)
+
+
+def test_three_fish_mean_pairwise() -> None:
+ row = _run(_fused(_three_fish())).iloc[0]
+ assert row["mean_iid_3d_cm"] == pytest.approx((5 + 12 + 13) / 3)
+
+
+def test_vertical_separation_counts_unlike_gl2() -> None:
+ pos = np.zeros((5, 2, 3))
+ pos[:, 1, 2] = 9.0
+ psess = _fused(pos)
+ assert _run(psess).iloc[0]["mean_iid_3d_cm"] == pytest.approx(9.0)
+ assert InterIndividualDistance().compute(psess).iloc[0]["mean_iid_px"] == pytest.approx(0.0)
+
+
+def test_at_least_the_nearest_neighbour_distance() -> None:
+ pos = np.random.default_rng(2).uniform(0, 40, (20, 5, 3))
+ psess = _fused(pos)
+ nnd = NearestNeighbourDistance3D().compute(psess).iloc[0]["mean_nnd_3d_cm"]
+ assert _run(psess).iloc[0]["mean_iid_3d_cm"] >= nnd
+
+
+def test_frames_with_a_nan_are_skipped_and_counted() -> None:
+ psess = _fused(_three_fish(5))
+ psess.depth[1, 2] = np.nan
+ psess.xy[3, 0, 0] = np.nan
+ row = _run(psess).iloc[0]
+ assert row["n_skipped_frames_3d"] == 2
+ assert row["frac_frames_used_3d"] == pytest.approx(3 / 5)
+ assert row["mean_iid_3d_cm"] == pytest.approx(10.0)
+
+
+def test_single_animal_session_is_refused_as_nan() -> None:
+ row = _run(_fused(np.zeros((6, 1, 3)), body_length_cm=2.0)).iloc[0]
+ assert np.isnan(row[STATS].astype(float)).all()
+ assert row["n_skipped_frames_3d"] == 6
+ assert np.isnan(row["frac_frames_used_3d"])
+
+
+def test_all_frames_skipped_is_nan_not_zero() -> None:
+ psess = _fused(_three_fish(4))
+ psess.depth[:, 0] = np.nan
+ row = _run(psess).iloc[0]
+ assert np.isnan(row[STATS].astype(float)).all()
+ assert row["n_skipped_frames_3d"] == 4
+
+
+@pytest.mark.parametrize("missing", ["depth", "px_per_cm", "depth_height_cm"])
+def test_session_without_depth_or_scale_gives_nan_columns(missing: str) -> None:
+ psess = _fused(_three_fish())
+ setattr(psess, missing, None)
+ df = _run(psess)
+ assert list(df.columns) == COLUMNS and len(df) == 1
+ assert np.isnan(df.iloc[0][STATS].astype(float)).all()
+ assert df.iloc[0]["n_skipped_frames_3d"] == 4
+
+
+def test_a_separator_row_is_not_a_frame() -> None:
+ psess = _fused(_three_fish(5))
+ separator = np.zeros(5, dtype=bool)
+ separator[2] = True
+ psess.separator_mask = separator
+ psess.xy[2] = np.nan
+ row = _run(psess).iloc[0]
+ assert row["n_skipped_frames_3d"] == 0
+ assert row["frac_frames_used_3d"] == pytest.approx(1.0)
diff --git a/tests/test_metrics/test_vertical_speed_3d.py b/tests/test_metrics/test_vertical_speed_3d.py
new file mode 100644
index 00000000..0ac793b0
--- /dev/null
+++ b/tests/test_metrics/test_vertical_speed_3d.py
@@ -0,0 +1,177 @@
+"""IL-18, vertical speed: |vz| in cm/s of a fused (or native 3-D) session."""
+
+from __future__ import annotations
+
+import warnings
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from tests.test_api import _make_psess, _make_session
+from track2data.core.models import KinematicsCfg
+from track2data.metrics import get
+from track2data.metrics.binning import bin_windows, slice_psess
+from track2data.metrics.individual import Speed3D, VerticalSpeed3D
+from track2data.preprocess.kinematics import compute_kinematics
+
+PX_PER_CM = 8.0
+HEIGHT_CM = 200.0
+FPS = 25.0
+COLUMNS = [
+ "session_id",
+ "metric_id",
+ "individual_id",
+ "mean_vertical_speed_cm_s",
+ "median_vertical_speed_cm_s",
+ "max_vertical_speed_cm_s",
+ "mean_vertical_speed_bl_s",
+]
+STATS = COLUMNS[3:]
+FWD = {"kinematics": {"method": "forward_difference"}}
+
+
+def _fused(pos_cm: np.ndarray, *, body_length_cm: float | None = None, kcfg=None):
+ n_frames, n_animals = pos_cm.shape[:2]
+ psess = _make_psess(_make_session(n_frames=n_frames, n_animals=n_animals))
+ if body_length_cm is not None:
+ psess.body_length_px = np.full(n_animals, body_length_cm * PX_PER_CM)
+ psess.xy = pos_cm[..., :2] * PX_PER_CM
+ psess.depth = pos_cm[..., 2] / HEIGHT_CM
+ psess.px_per_cm = PX_PER_CM
+ psess.depth_height_cm = HEIGHT_CM
+ psess.kinematics = compute_kinematics(psess.xy, FPS, kcfg)
+ return psess
+
+
+def _ramp(n_frames: int, per_frame=(0.0, 0.0, 1.0), n_animals: int = 1) -> np.ndarray:
+ t = np.arange(n_frames, dtype=float)[:, None, None]
+ return np.broadcast_to(t * np.array(per_frame), (n_frames, n_animals, 3)).copy()
+
+
+def _run(psess, cfg=None) -> pd.DataFrame:
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", RuntimeWarning)
+ return VerticalSpeed3D().compute(psess, cfg)
+
+
+def test_it_is_registered_with_requires_depth_scale() -> None:
+ cls = get("IL-18")
+ assert cls is VerticalSpeed3D
+ assert (cls.name, cls.label, cls.level) == (
+ "vertical_speed_3d",
+ "Vertical Speed (mean / median / max)",
+ "individual",
+ )
+ assert cls.requires_identity is True
+ assert cls.priority == "optional"
+ assert cls.requires_depth_scale is True
+ assert cls.uses_kinematics_cfg is True
+ assert cls.window_safe is True
+ assert cls.output_columns == COLUMNS
+
+
+def test_a_constant_vertical_ramp_gives_the_ramp_speed() -> None:
+ # 1 cm per frame at 25 fps = 25 cm/s.
+ row = _run(_fused(_ramp(30), body_length_cm=5.0)).iloc[0]
+ assert row["mean_vertical_speed_cm_s"] == pytest.approx(25.0)
+ assert row["median_vertical_speed_cm_s"] == pytest.approx(25.0)
+ assert row["max_vertical_speed_cm_s"] == pytest.approx(25.0)
+ assert row["mean_vertical_speed_bl_s"] == pytest.approx(5.0) # 25 cm/s / 5 cm
+
+
+def test_a_dive_counts_like_a_climb() -> None:
+ up = _run(_fused(_ramp(30, per_frame=(0, 0, 1.0)))).iloc[0]
+ down = _run(_fused(_ramp(30, per_frame=(0, 0, -1.0)) + np.array([0, 0, 40.0]))).iloc[0]
+ assert down["mean_vertical_speed_cm_s"] == pytest.approx(up["mean_vertical_speed_cm_s"])
+
+
+def test_horizontal_motion_gives_zero() -> None:
+ rng = np.random.default_rng(1)
+ pos = np.cumsum(rng.normal(0, 1.0, (40, 2, 3)), axis=0)
+ pos[..., 2] = 7.0 # constant depth
+ df = _run(_fused(pos))
+ assert df["mean_vertical_speed_cm_s"].to_numpy() == pytest.approx(0.0, abs=1e-9)
+ assert df["max_vertical_speed_cm_s"].to_numpy() == pytest.approx(0.0, abs=1e-9)
+
+
+def test_it_is_the_vertical_part_of_il17() -> None:
+ # Pure vertical motion: IL-17's speed is |vz| exactly, so the two metrics agree.
+ psess = _fused(_ramp(30))
+ il18 = _run(psess).iloc[0]["mean_vertical_speed_cm_s"]
+ il17 = Speed3D().compute(psess).iloc[0]["mean_speed_3d_cm_s"]
+ assert il18 == pytest.approx(il17)
+
+
+def test_it_uses_session_z_speed_when_present() -> None:
+ psess = _fused(_ramp(20))
+ psess.z_speed = np.full((20, 1), 3.0)
+ assert _run(psess).iloc[0]["mean_vertical_speed_cm_s"] == pytest.approx(3.0)
+
+
+def test_estimator_follows_the_kinematics_config() -> None:
+ t = np.arange(20, dtype=float)
+ pos = np.zeros((20, 1, 3))
+ pos[:, 0, 2] = 0.1 * t**2
+ row = _run(_fused(pos), FWD).iloc[0]
+ steps = np.diff(pos[:, 0, 2]) * FPS # the last frame is NaN and left out
+ assert row["mean_vertical_speed_cm_s"] == pytest.approx(steps.mean())
+ assert row["max_vertical_speed_cm_s"] == pytest.approx(steps.max())
+ assert _run(_fused(pos)).iloc[0]["max_vertical_speed_cm_s"] != pytest.approx(
+ row["max_vertical_speed_cm_s"]
+ )
+ kcfg = {"kinematics": KinematicsCfg(method="forward_difference")}
+ assert _run(_fused(pos), kcfg).iloc[0]["mean_vertical_speed_cm_s"] == pytest.approx(
+ steps.mean()
+ )
+
+
+def test_nan_depth_frames_are_excluded_not_counted_as_zero() -> None:
+ psess = _fused(_ramp(30))
+ psess.depth[10:13, 0] = np.nan
+ row = _run(psess).iloc[0]
+ assert row["mean_vertical_speed_cm_s"] == pytest.approx(25.0)
+ assert row["median_vertical_speed_cm_s"] == pytest.approx(25.0)
+
+
+def test_an_all_nan_animal_is_nan_and_others_unaffected() -> None:
+ psess = _fused(np.concatenate([_ramp(20), _ramp(20)], axis=1))
+ psess.depth[:, 1] = np.nan
+ df = _run(psess)
+ assert df.loc[1, STATS].isna().all()
+ assert df.loc[0, "mean_vertical_speed_cm_s"] == pytest.approx(25.0)
+
+
+def test_unknown_body_length_gives_nan_bl_only() -> None:
+ row = _run(_fused(_ramp(20))).iloc[0]
+ assert np.isnan(row["mean_vertical_speed_bl_s"])
+ assert row["mean_vertical_speed_cm_s"] == pytest.approx(25.0)
+
+
+@pytest.mark.parametrize("missing", ["depth", "px_per_cm", "depth_height_cm"])
+def test_it_is_unavailable_without_a_depth_scale(missing: str) -> None:
+ psess = _fused(_ramp(20, n_animals=2))
+ setattr(psess, missing, None)
+ df = _run(psess)
+ assert list(df.columns) == COLUMNS
+ assert df["individual_id"].tolist() == [0, 1]
+ assert df[STATS].isna().all().all()
+
+
+def test_binned_values_match_the_windows_of_the_whole_session() -> None:
+ psess = _fused(_ramp(100, per_frame=(0.6, 0.8, 1.0)))
+ whole = _run(psess).loc[0, "mean_vertical_speed_cm_s"]
+ windows = bin_windows(psess, 1.0)
+ assert len(windows) == 4
+ for w in windows:
+ part = _run(slice_psess(psess, w.start_row, w.stop_row))
+ assert part.loc[0, "mean_vertical_speed_cm_s"] == pytest.approx(whole)
+ assert part.loc[0, "mean_vertical_speed_cm_s"] == pytest.approx(25.0)
+
+
+def test_a_native_3d_session_uses_its_absolute_z() -> None:
+ psess = _make_psess(_make_session(n_frames=30, n_animals=1))
+ psess.px_per_cm = 1.0 # a native file is in the project unit
+ psess.z = (np.arange(30, dtype=float) * 2.0)[:, None] # 2 units per frame = 50 units/s
+ psess.kinematics = compute_kinematics(psess.xy, FPS, None)
+ assert _run(psess).iloc[0]["mean_vertical_speed_cm_s"] == pytest.approx(50.0)
diff --git a/tests/test_metrics/test_zone_timeline.py b/tests/test_metrics/test_zone_timeline.py
index a2dd7d34..c725ae01 100644
--- a/tests/test_metrics/test_zone_timeline.py
+++ b/tests/test_metrics/test_zone_timeline.py
@@ -128,6 +128,35 @@ def test_contiguous_adjacent_intervals_have_no_cut(self) -> None:
assert not events["after_gap"].any()
+class TestSeparatorRows:
+ def _with_separator(self, in_zone_rows: list[int]) -> PreprocessedSession:
+ # rows 0-9 are frames 0-9, row 10 is the separator for the unfilled gap,
+ # rows 11-20 are frames 20-29
+ n = 21
+ psess = _psess(in_zone_rows, intervals=[(0, 10), (20, 30)], n=n)
+ sep = np.zeros(n, dtype=bool)
+ sep[10] = True
+ frames = np.array([*range(10), 10, *range(20, 30)])
+ psess.frame_index = frames
+ psess.separator_mask = sep
+ psess.tracked_mask = ~sep
+ return psess
+
+ def test_no_exit_is_fabricated_on_the_separator_row(self) -> None:
+ # inside zone A for the whole of both intervals (the separator row is not in A)
+ rows = [r for r in range(21) if r != 10]
+ events = Z5EntryExitEvents().compute(self._with_separator(rows))
+ events = events.reset_index(drop=True)
+ assert list(events["event"]) == ["enter", "enter"]
+ assert list(events["frame"]) == [0, 20]
+ assert list(events["after_gap"]) == [False, True]
+
+ def test_a_real_exit_before_the_separator_is_kept(self) -> None:
+ events = Z5EntryExitEvents().compute(self._with_separator([6, 7, 8]))
+ assert list(events["event"]) == ["enter", "exit"]
+ assert list(events["frame"]) == [6, 9]
+
+
class TestLatency:
def test_it_is_time_since_the_start_of_tracking_and_says_where_that_is(self) -> None:
psess = _psess(list(range(10, 20)), intervals=[(1000, 1020)])
diff --git a/tests/test_native3d/test_gating.py b/tests/test_native3d/test_gating.py
index 466db8b1..26fa7b6e 100644
--- a/tests/test_native3d/test_gating.py
+++ b/tests/test_native3d/test_gating.py
@@ -16,8 +16,8 @@
)
PIXEL_SIZED = ("IL-3", "IL-9", "IL-10", "IL-14")
-GROUP = ("GL-1", "GL-3", "GL-5", "GL-7", "GL-16")
-STILL_OFFERED = ("IL-1", "IL-2", "IL-16", "IL-17")
+GROUP = ("GL-1", "GL-3", "GL-5", "GL-7", "GL-16", "GL-18", "GL-19")
+STILL_OFFERED = ("IL-1", "IL-2", "IL-16", "IL-17", "IL-18")
def _native():
diff --git a/tests/test_native3d/test_no_pixel_frame.py b/tests/test_native3d/test_no_pixel_frame.py
index e4be8f1f..344592b6 100644
--- a/tests/test_native3d/test_no_pixel_frame.py
+++ b/tests/test_native3d/test_no_pixel_frame.py
@@ -293,7 +293,7 @@ def test_readme_native_block_counts_the_samples_outside_the_water_column(tmp_pat
tmp_path, WaterColumn(surface_z=0.0, floor_z=20.0), outside_samples=5
)
assert (
- "| Samples outside the water column | 5 (left out of IL-15, IL-16, IL-17 and GL-16, "
+ "| Samples outside the water column | 5 (left out of IL-15, IL-16, IL-17, IL-18 and GL-16, "
"never clipped) |" in text
)
diff --git a/tests/test_readers/idtrackerai/test_blobs.py b/tests/test_readers/idtrackerai/test_blobs.py
index 077c036b..c46f2a24 100644
--- a/tests/test_readers/idtrackerai/test_blobs.py
+++ b/tests/test_readers/idtrackerai/test_blobs.py
@@ -124,6 +124,33 @@ class Evil:
with pytest.raises(pickle.UnpicklingError, match="not in the allowlist"):
_AllowlistUnpickler(io.BytesIO(data)).load()
+ def test_refuses_code_executing_numpy_helpers(self) -> None:
+ """A module-prefix match on 'numpy' once admitted
+ numpy.testing._private.utils.runstring (exec)."""
+ import io
+
+ payload = (
+ b"cnumpy.testing._private.utils\nrunstring\n"
+ b"(S'raise SystemExit(99)'\n(dtR."
+ )
+ with pytest.raises(pickle.UnpicklingError, match="not in the allowlist"):
+ _AllowlistUnpickler(io.BytesIO(payload)).load()
+
+ @pytest.mark.parametrize(
+ "obj",
+ [
+ np.arange(6, dtype=np.float64).reshape(2, 3),
+ np.array([1, 2], dtype=np.int32),
+ np.float64(1.5),
+ np.array([("a", 1)], dtype=[("n", "U1"), ("v", "i4")]),
+ ],
+ )
+ def test_still_loads_numpy_arrays_and_scalars(self, obj) -> None:
+ import io
+
+ back = _AllowlistUnpickler(io.BytesIO(pickle.dumps(obj))).load()
+ assert np.array_equal(back, obj)
+
def test_loads_real_pickle_without_idtrackerai_installed(
self, tmp_path: Path, fake_idtrackerai_classes
) -> None:
diff --git a/tests/test_readers/test_advisories.py b/tests/test_readers/test_advisories.py
index 46109569..ba9bf998 100644
--- a/tests/test_readers/test_advisories.py
+++ b/tests/test_readers/test_advisories.py
@@ -213,3 +213,43 @@ def session(body_length: np.ndarray | None) -> Session:
without = SessionSummary.from_session(session(None), calibration_mode="x")
assert with_it.has_body_length is True
assert without.has_body_length is False
+
+
+class TestFragmentedSessions:
+ @staticmethod
+ def _fragmented(session_id: str, fraction: float | None, n_animals: int = 4):
+ return SessionSummary(
+ session_id=session_id,
+ reader="adv_verified",
+ fps=25.0,
+ n_frames=100,
+ n_animals=n_animals,
+ width_px=100,
+ height_px=80,
+ length_unit=None,
+ calibration_mode="scalar",
+ nan_slot_fraction=fraction,
+ )
+
+ def test_a_fragment_heavy_session_gets_an_advisory_that_names_top_n(self) -> None:
+ (text,) = reader_advisories([self._fragmented("flies", 0.8)])
+ assert "flies" in text and "80%" in text
+ assert "top_n" in text
+ assert "any animal is missing" in text
+
+ def test_a_summary_built_without_the_new_field_never_warns(self) -> None:
+ assert reader_advisories([self._fragmented("x", None)]) == []
+
+ def test_a_single_animal_session_never_warns(self) -> None:
+ assert reader_advisories([self._fragmented("x", 0.9, n_animals=1)]) == []
+
+ def test_a_session_below_the_threshold_does_not_warn(self) -> None:
+ from track2data.readers.advisories import FRAGMENTED_NAN_FRACTION
+
+ assert reader_advisories([self._fragmented("x", FRAGMENTED_NAN_FRACTION - 0.01)]) == []
+ assert reader_advisories([self._fragmented("x", FRAGMENTED_NAN_FRACTION)]) != []
+
+ def test_many_fragmented_sessions_are_truncated_with_plus_n_more(self) -> None:
+ (text,) = reader_advisories([self._fragmented(f"s{i}", 0.9) for i in range(7)])
+ assert "s0" in text and "s3" in text and "s4" not in text
+ assert "+3 more" in text
diff --git a/tests/test_readers/test_confirm.py b/tests/test_readers/test_confirm.py
index e35dba55..d82c61a5 100644
--- a/tests/test_readers/test_confirm.py
+++ b/tests/test_readers/test_confirm.py
@@ -165,6 +165,48 @@ def test_a_value_the_user_types_replaces_the_proposal_and_says_so(self) -> None:
assert draft.options == {"fps": 25.0}
assert draft.option_source("fps") == "user"
+ def test_prefill_from_video_sets_values_with_source_video_and_leaves_user_values_alone(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from track2data.readers import confirm
+ from track2data.readers.video_meta import VideoProbe
+
+ monkeypatch.setattr(confirm, "probe_video", lambda p: VideoProbe(25.0, 640, 480, 100))
+ height = ReaderParameter(name="height_px", label="Frame height", kind="int", required=True)
+ draft = ConfirmDraft(result((detection("alpha", parameters=(FPS, SIZE, height)),)))
+ draft.set_option("fps", 30.0) # typed by the user: the video must not overwrite it
+ probe = draft.prefill_from_video(Path("clip.mp4"))
+ assert probe is not None
+ assert draft.options == {"fps": 30.0, "width_px": 640, "height_px": 480}
+ assert draft.option_source("fps") == "user"
+ assert draft.option_source("width_px") == draft.option_source("height_px") == "video"
+
+ def test_an_unreadable_video_changes_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ from track2data.readers import confirm
+
+ monkeypatch.setattr(confirm, "probe_video", lambda p: None)
+ draft = ConfirmDraft(result((detection("alpha", parameters=(FPS,)),)))
+ assert draft.prefill_from_video(Path("clip.mp4")) is None
+ assert draft.options == {}
+
+ def test_options_the_reader_does_not_have_are_not_filled(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from track2data.readers import confirm
+ from track2data.readers.video_meta import VideoProbe
+
+ monkeypatch.setattr(confirm, "probe_video", lambda p: VideoProbe(25.0, 640, 480, None))
+ draft = ConfirmDraft(result((detection("alpha", parameters=(FPS,)),)))
+ draft.prefill_from_video(Path("clip.mp4"))
+ assert draft.options == {"fps": 25.0}
+
+ def test_a_video_probe_is_only_offered_when_the_reader_asks_for_fps_or_size(self) -> None:
+ assert ConfirmDraft(result((detection("alpha", parameters=(FPS,)),))).accepts_video_probe
+ assert not ConfirmDraft(result((detection("alpha"),))).accepts_video_probe
+ assert not ConfirmDraft(
+ result((detection("alpha", parameters=(ARENA,)),))
+ ).accepts_video_probe
+
def test_clearing_a_value_leaves_a_required_option_missing_again(self) -> None:
proposed = {"fps": ProposedValue(value=30.0, source="file")}
draft = ConfirmDraft(result((detection("alpha", parameters=(FPS,), proposed=proposed),)))
@@ -230,6 +272,32 @@ def test_the_same_folder_saved_under_the_same_reader_is_a_duplicate(self) -> Non
draft = ConfirmDraft(result((detection("alpha"),)), existing=existing)
assert {r.original_id for r in draft.rows if r.already_added} == {"a"}
+ def test_a_whole_frame_candidate_is_offered_again_after_the_folder_was_split_into_panels(
+ self,
+ ) -> None:
+ from track2data.core.models import PanelRect
+
+ existing = [
+ SessionRef(
+ session_id="a__top",
+ folder=Path("root") / "a",
+ sha256="",
+ reader="alpha",
+ panel=PanelRect(x=0, y=0, width=10, height=10),
+ )
+ ]
+ draft = ConfirmDraft(result((detection("alpha"),)), existing=existing)
+ by_id = {r.original_id: r for r in draft.rows}
+ assert not by_id["a"].already_added and by_id["a"].included
+
+ def test_a_true_duplicate_is_still_left_out(self) -> None:
+ existing = [
+ SessionRef(session_id="old", folder=Path("root") / "a", sha256="", reader="alpha")
+ ]
+ draft = ConfirmDraft(result((detection("alpha"),)), existing=existing)
+ by_id = {r.original_id: r for r in draft.rows}
+ assert by_id["a"].already_added and not by_id["a"].included
+
def test_choosing_another_reader_keeps_what_was_already_in_the_project(self) -> None:
existing = [SessionRef(session_id="old", folder=Path("root") / "a", sha256="")]
draft = ConfirmDraft(result((detection("alpha"), detection("beta"))), existing=existing)
@@ -378,3 +446,57 @@ def test_an_idtracker_folder_of_sessions_becomes_a_project_ready_to_run(
manifest = ProjectManifest(project_name="p", created_at=now, updated_at=now, sessions=refs)
session = Engine(manifest).import_ref(refs[0])
assert session.n_animals == 2 and session.session_id == refs[0].session_id
+
+
+class TestOfferReader:
+ """The "choose software" retry: a reader the scan did not recognise, tried with looser rules."""
+
+ @staticmethod
+ def _scan_of_a_txt_table(tmp_path: Path) -> ScanResult:
+ import numpy as np
+
+ from tests.support.dlc import random_pose, write_dlc_csv
+ from track2data.readers.scan import scan
+
+ write_dlc_csv(
+ tmp_path / "trial1.txt",
+ random_pose(20, 1, 3),
+ np.ones((20, 1, 3)),
+ bodyparts=["a", "b", "c"],
+ )
+ return scan([tmp_path])
+
+ def test_offer_reader_then_select_reader_succeeds_and_other_names_still_raise(
+ self, tmp_path: Path
+ ) -> None:
+ draft = ConfirmDraft(self._scan_of_a_txt_table(tmp_path))
+ assert draft.is_empty
+ with pytest.raises(ValueError):
+ draft.select_reader("deeplabcut") # not offered yet
+ assert draft.offer_reader("deeplabcut") is True
+ assert not draft.is_empty
+ assert draft.reader == "deeplabcut"
+ assert draft.confidence is Confidence.LOW
+ assert draft.chosen_by == "user"
+ draft.select_reader("deeplabcut")
+ with pytest.raises(ValueError, match="No reader"):
+ draft.select_reader("sleap_analysis")
+
+ def test_a_reader_with_nothing_to_offer_leaves_the_draft_alone(self, tmp_path: Path) -> None:
+ draft = ConfirmDraft(self._scan_of_a_txt_table(tmp_path))
+ assert draft.offer_reader("sleap_analysis") is False
+ assert draft.offer_reader("idtrackerai") is False
+ assert draft.is_empty
+
+ def test_an_offered_session_reads_through_the_readers_own_read(self, tmp_path: Path) -> None:
+ from track2data import readers
+
+ draft = ConfirmDraft(self._scan_of_a_txt_table(tmp_path))
+ draft.offer_reader("deeplabcut")
+ (row,) = draft.rows
+ session = readers.read_session(
+ row.source,
+ reader="deeplabcut",
+ options={"fps": 30.0, "width_px": 640, "height_px": 480},
+ )
+ assert session.n_animals == 1
diff --git a/tests/test_readers/test_deeplabcut_csv.py b/tests/test_readers/test_deeplabcut_csv.py
index 3eda99c7..bc7db640 100644
--- a/tests/test_readers/test_deeplabcut_csv.py
+++ b/tests/test_readers/test_deeplabcut_csv.py
@@ -447,6 +447,19 @@ def test_one_animal_called_ind1_is_neither_flagged_nor_unstable(self, tmp_path:
assert session.warnings == ()
assert read(f, keypoint="snout").has_stable_identities is True
+ def test_positional_animals_are_declared_identity_free(self, tmp_path: Path) -> None:
+ """So the engine's identity-free gate fires; slot k is not the same animal over time."""
+ f = multi(tmp_path / "m.csv", animals=("ind1", "ind2"))
+ session = read(f, keypoint="snout")
+ assert session.has_stable_identities is False
+ assert session.track_wo_identities is True
+
+ def test_named_animals_are_not_declared_identity_free(self, tmp_path: Path) -> None:
+ f = multi(tmp_path / "m.csv", animals=("mouse1", "mouse2"))
+ session = read(f, keypoint="snout")
+ assert session.has_stable_identities is True
+ assert session.track_wo_identities is None
+
def test_choosing_animals_in_a_single_animal_file_says_there_are_none_to_choose(
self, tmp_path: Path
) -> None:
diff --git a/tests/test_readers/test_deeplabcut_h5.py b/tests/test_readers/test_deeplabcut_h5.py
new file mode 100644
index 00000000..e1f76057
--- /dev/null
+++ b/tests/test_readers/test_deeplabcut_h5.py
@@ -0,0 +1,223 @@
+"""The DeepLabCut ``.h5`` reader: pandas ``table`` files, labels decoded without running code.
+
+Files are written by ``tests/support/dlc.write_dlc_h5`` (h5py alone, in the layout PyTables gives
+pandas; one test checks pandas itself reads them back). The real sample is exercised by the
+opt-in suite in ``tests/real_samples``.
+"""
+
+from __future__ import annotations
+
+import pickle
+from pathlib import Path
+
+import h5py
+import numpy as np
+import pytest
+
+from tests.support.dlc import random_pose, write_dlc_csv, write_dlc_h5
+from tests.support.sleap import random_tracks, write_sleap_analysis
+from track2data import readers
+from track2data.core.errors import Track2DataError
+from track2data.readers.detection import Confidence
+from track2data.readers.scan import scan
+
+BODYPARTS = ["snout", "ear", "tail"]
+VIDEO = {"fps": 30.0, "width_px": 640, "height_px": 480}
+
+
+def pair(tmp_path: Path, *, n_animals: int = 1, seed: int = 0):
+ """The same pose written as CSV and as h5 (with likelihoods, one NaN cell)."""
+ xy = random_pose(40, n_animals, len(BODYPARTS), seed=seed)
+ xy[3, 0, 1] = np.nan
+ lik = np.random.default_rng(seed).uniform(0.7, 1.0, size=xy.shape[:3])
+ individuals = [f"mouse{i}" for i in range(n_animals)] if n_animals > 1 else None
+ kw = {"bodyparts": BODYPARTS, "individuals": individuals}
+ csv = write_dlc_csv(tmp_path / "csv" / "trial.csv", xy, lik, **kw)
+ h5 = write_dlc_h5(tmp_path / "h5" / "trial.h5", xy, lik, **kw)
+ return csv, h5
+
+
+def read(path: Path, reader: str = "deeplabcut_h5", **options):
+ return readers.read_session(path, reader=reader, options={**VIDEO, **options})
+
+
+def test_dlc_h5_matches_the_csv_reader_on_the_same_pose(tmp_path: Path) -> None:
+ csv, h5 = pair(tmp_path)
+ a, b = read(csv, "deeplabcut"), read(h5)
+ np.testing.assert_allclose(a.raw_xy, b.raw_xy, rtol=1e-12, equal_nan=True)
+ assert a.keypoints is not None and b.keypoints is not None
+ np.testing.assert_allclose(a.keypoints.xy, b.keypoints.xy, rtol=1e-6, equal_nan=True)
+ assert a.keypoints.names == b.keypoints.names
+ assert b.reader == "deeplabcut_h5"
+ assert b.trajectory_format == "deeplabcut_h5"
+
+
+def test_a_multi_animal_file_matches_too_and_blocks_may_be_split(tmp_path: Path) -> None:
+ xy = random_pose(30, 2, 3)
+ lik = np.random.default_rng(1).uniform(0.7, 1.0, size=xy.shape[:3])
+ kw = {"bodyparts": BODYPARTS, "individuals": ["m1", "m2"]}
+ csv = write_dlc_csv(tmp_path / "a.csv", xy, lik, **kw)
+ h5 = write_dlc_h5(tmp_path / "a.h5", xy, lik, split_blocks=True, **kw)
+ a, b = read(csv, "deeplabcut"), read(h5)
+ np.testing.assert_allclose(a.raw_xy, b.raw_xy, rtol=1e-12, equal_nan=True)
+ assert b.identities_labels == ["m1", "m2"]
+
+
+def test_an_h5_and_csv_pair_is_one_session(tmp_path: Path) -> None:
+ csv, h5 = pair(tmp_path)
+ folder = tmp_path / "both"
+ folder.mkdir()
+ (folder / "trial.csv").write_bytes(csv.read_bytes())
+ (folder / "trial.h5").write_bytes(h5.read_bytes())
+ result = scan([folder])
+ sessions = [
+ (d.reader, s.source.name) for g in result.groups for d in g.detections for s in d.sessions
+ ]
+ assert sessions == [("deeplabcut", "trial.csv")]
+
+
+def test_an_h5_alone_is_detected_with_medium_confidence(tmp_path: Path) -> None:
+ _, h5 = pair(tmp_path)
+ (group,) = scan([h5.parent]).groups
+ assert group.best.reader == "deeplabcut_h5"
+ assert group.best.confidence is Confidence.MEDIUM
+ assert [s.source for s in group.best.sessions] == [h5]
+
+
+def test_scan_never_unpickles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ _, h5 = pair(tmp_path)
+
+ def boom(*args, **kwargs):
+ raise AssertionError("the scan unpickled something")
+
+ monkeypatch.setattr(pickle, "loads", boom)
+ monkeypatch.setattr(pickle, "load", boom)
+ from track2data.readers import deeplabcut_h5
+
+ monkeypatch.setattr(deeplabcut_h5, "_LabelUnpickler", boom)
+ (group,) = scan([h5.parent]).groups
+ assert group.best.reader == "deeplabcut_h5"
+
+
+class _Evil:
+ def __reduce__(self):
+ import os
+
+ return (os.system, ("touch " + str(MARKER),))
+
+
+MARKER = Path("/tmp/claude-evil-marker-should-not-exist")
+
+
+def test_a_hostile_pickled_label_is_refused_with_a_coded_error(tmp_path: Path) -> None:
+ MARKER.unlink(missing_ok=True)
+ xy = random_pose(10, 1, 3)
+ h5 = write_dlc_h5(
+ tmp_path / "evil.h5",
+ xy,
+ bodyparts=BODYPARTS,
+ label_pickle=pickle.dumps([(1, [_Evil()])], protocol=0),
+ )
+ with pytest.raises(Track2DataError) as caught:
+ read(h5)
+ assert caught.value.code == "DLCH5_LABELS_REFUSED"
+ assert caught.value.remediation
+ assert not MARKER.exists()
+
+
+def test_an_oversized_label_attribute_is_refused_unread(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from track2data.readers import deeplabcut_h5
+
+ _, h5 = pair(tmp_path)
+ monkeypatch.setattr(deeplabcut_h5, "_MAX_LABEL_BYTES", 10)
+ with pytest.raises(Track2DataError) as caught:
+ read(h5)
+ assert caught.value.code == "DLCH5_LABELS_REFUSED"
+
+
+def test_sleap_and_dlc_h5_never_both_claim_a_file(tmp_path: Path) -> None:
+ _, dlc = pair(tmp_path)
+ tracks = random_tracks(30, 2, 3)
+ sleap = write_sleap_analysis(
+ tmp_path / "s" / "x.analysis.h5",
+ tracks,
+ node_names=BODYPARTS,
+ track_names=["a", "b"],
+ )
+ claims: dict[Path, set[str]] = {}
+ for folder in (dlc.parent, sleap.parent):
+ for group in scan([folder]).groups:
+ for d in group.detections:
+ for s in d.sessions:
+ claims.setdefault(s.source, set()).add(d.reader)
+ assert claims == {dlc: {"deeplabcut_h5"}, sleap: {"sleap_analysis"}}
+
+
+def test_a_tracks_group_h5_is_refused_with_a_remediation(tmp_path: Path) -> None:
+ path = tmp_path / "video_el.h5"
+ with h5py.File(path, "w") as handle:
+ handle.create_group("tracks")
+ with pytest.raises(Track2DataError) as caught:
+ read(path)
+ assert caught.value.code == "DLCH5_LAYOUT_UNSUPPORTED"
+ assert "tracklet" in str(caught.value)
+
+
+def test_a_file_that_is_not_hdf5_is_unreadable(tmp_path: Path) -> None:
+ path = tmp_path / "x.h5"
+ path.write_bytes(b"not hdf5")
+ with pytest.raises(Track2DataError) as caught:
+ read(path)
+ assert caught.value.code == "DLCH5_UNREADABLE"
+
+
+def test_a_table_without_likelihood_is_not_a_prediction_file(tmp_path: Path) -> None:
+ _csv, h5 = pair(tmp_path)
+ # Replace the pickled labels so no column is a likelihood.
+ labels = [("scorer", "a", "x"), ("scorer", "a", "y")]
+ with h5py.File(h5, "r+") as handle:
+ group = handle["df_with_missing"]
+ table = group["table"]
+ del group["table"]
+ data = np.zeros(2, dtype=[("index", " None:
+ pd = pytest.importorskip("pandas")
+ pytest.importorskip("tables")
+ _, h5 = pair(tmp_path)
+ frame = pd.read_hdf(h5, key="df_with_missing")
+ assert frame.shape == (40, 9)
+ assert frame.columns.nlevels == 3
+ read_back = read(h5)
+ np.testing.assert_allclose(
+ read_back.keypoints.xy[:, 0, 0, 0], # type: ignore[union-attr]
+ frame[(frame.columns[0][0], "snout", "x")].to_numpy(),
+ )
+
+
+def test_a_pandas_written_file_is_read_without_pytables(tmp_path: Path) -> None:
+ pd = pytest.importorskip("pandas")
+ pytest.importorskip("tables")
+ columns = pd.MultiIndex.from_product(
+ [["DLC_scorer"], BODYPARTS, ["x", "y", "likelihood"]],
+ names=["scorer", "bodyparts", "coords"],
+ )
+ values = np.random.default_rng(0).uniform(0, 100, size=(25, 9))
+ path = tmp_path / "pandas_made.h5"
+ pd.DataFrame(values, columns=columns).to_hdf(
+ path, key="df_with_missing", format="table", mode="w"
+ )
+ session = read(path, likelihood_cutoff=0.0)
+ assert session.n_frames == 25
+ assert session.keypoints is not None and session.keypoints.names == BODYPARTS
diff --git a/tests/test_readers/test_idtrackerai_v5.py b/tests/test_readers/test_idtrackerai_v5.py
index 56b24254..21128107 100644
--- a/tests/test_readers/test_idtrackerai_v5.py
+++ b/tests/test_readers/test_idtrackerai_v5.py
@@ -125,19 +125,19 @@ def test_frame0_values_correct(self, reader: IDTrackerAiV5Reader,
def test_fallback_to_with_gaps(self, reader: IDTrackerAiV5Reader,
tiny_v5_no_wo_gaps: Path) -> None:
- s = reader.read(tiny_v5_no_wo_gaps)
+ s = reader.read(tiny_v5_no_wo_gaps, allow_pickle=True)
assert s.trajectory_variant == "with_gaps"
def test_fallback_has_nans_at_gap_frames(self, reader: IDTrackerAiV5Reader,
tiny_v5_no_wo_gaps: Path) -> None:
- s = reader.read(tiny_v5_no_wo_gaps)
+ s = reader.read(tiny_v5_no_wo_gaps, allow_pickle=True)
for f in TINY_V5_GAP_FRAMES:
assert np.isnan(s.raw_xy[f, 0, 0]), f"Expected NaN at frame {f}"
def test_transposed_input_corrected(self, reader: IDTrackerAiV5Reader,
tiny_v5_transposed: Path) -> None:
"""(n_animals, n_frames, 2) input must be transposed to canonical form."""
- s = reader.read(tiny_v5_transposed)
+ s = reader.read(tiny_v5_transposed, allow_pickle=True)
assert s.raw_xy.shape == (TINY_V5_N_FRAMES, TINY_V5_N_ANIMALS, 2)
@@ -194,7 +194,7 @@ def test_unstable_when_animal_below_threshold(self, reader: IDTrackerAiV5Reader,
"paths_to_video_files": [],
}
np.save(tmp_path / "video_object.npy", video_obj)
- s = reader.read(tmp_path)
+ s = reader.read(tmp_path, allow_pickle=True)
assert s.has_stable_identities is False
def test_body_length_px_is_none_when_absent(self, reader: IDTrackerAiV5Reader,
@@ -226,3 +226,48 @@ def test_raises_when_no_trajectory_file(self, reader: IDTrackerAiV5Reader,
"height": 1080, "width": 1920})
with pytest.raises(DataValidationError, match="TRAJ_NOT_FOUND"):
reader.read(tmp_path)
+
+
+class TestMissingFrameRate:
+ """A guessed frame rate scales every speed and duration, so it is an error, not a default."""
+
+ def _folder(self, tmp_path: Path, video_obj: dict) -> Path:
+ traj_dir = tmp_path / "trajectories"
+ traj_dir.mkdir()
+ np.save(traj_dir / "trajectories_wo_gaps.npy", np.ones((20, 2, 2)))
+ np.save(tmp_path / "video_object.npy", video_obj)
+ return tmp_path
+
+ def test_no_fps_anywhere_raises(self, reader: IDTrackerAiV5Reader, tmp_path: Path) -> None:
+ folder = self._folder(tmp_path, {"frame_number": 20, "height": 100, "width": 100})
+ with pytest.raises(DataValidationError, match="VIDEO_FPS_MISSING"):
+ reader.read(folder, allow_pickle=True)
+
+ def test_refused_pickle_and_no_session_json_raises(
+ self, reader: IDTrackerAiV5Reader, tmp_path: Path
+ ) -> None:
+ folder = self._folder(tmp_path, {"fps": 25.0, "frame_number": 20})
+ with pytest.raises(DataValidationError, match="VIDEO_FPS_MISSING"):
+ reader.read(folder)
+
+ def test_a_given_frame_rate_fills_the_gap(
+ self, reader: IDTrackerAiV5Reader, tmp_path: Path
+ ) -> None:
+ folder = self._folder(tmp_path, {"frame_number": 20, "height": 9, "width": 9})
+ assert reader.read(folder, allow_pickle=True, options={"fps": 30}).video.fps == 30.0
+
+ def test_the_session_json_wins_over_a_given_frame_rate(
+ self, reader: IDTrackerAiV5Reader, tmp_path: Path
+ ) -> None:
+ import json
+
+ folder = self._folder(tmp_path, {"frame_number": 20, "height": 9, "width": 9})
+ (folder / "session.json").write_text(json.dumps({"frames_per_second": 50}))
+ video = reader.read(folder, allow_pickle=True, options={"fps": 30}).video
+ assert video.fps == 50.0
+
+ def test_a_real_one_fps_recording_is_kept(
+ self, reader: IDTrackerAiV5Reader, tmp_path: Path
+ ) -> None:
+ folder = self._folder(tmp_path, {"fps": 1.0, "frame_number": 20, "height": 9, "width": 9})
+ assert reader.read(folder, allow_pickle=True).video.fps == 1.0
diff --git a/tests/test_readers/test_sleap_analysis.py b/tests/test_readers/test_sleap_analysis.py
index 08fb7175..5a1147b2 100644
--- a/tests/test_readers/test_sleap_analysis.py
+++ b/tests/test_readers/test_sleap_analysis.py
@@ -362,3 +362,17 @@ def test_a_second_axis_that_is_not_x_and_y_is_refused(self, tmp_path: Path) -> N
with pytest.raises(Track2DataError) as caught:
read(f)
assert caught.value.code == "SLEAP_LAYOUT_AMBIGUOUS" and "not 2" in str(caught.value)
+
+
+def test_an_analysis_file_with_another_extension_is_offered_only_by_the_lenient_retry(
+ tmp_path: Path,
+) -> None:
+ from track2data.readers.scan import lenient_scan
+
+ analysis(tmp_path / "trial1.analysis")
+ result = scan([tmp_path])
+ assert result.groups == ()
+ group = lenient_scan(result, "sleap_analysis")
+ assert group is not None and group.best.confidence is Confidence.LOW
+ session = read(group.best.sessions[0].source)
+ assert session.n_animals == 2
diff --git a/tests/test_readers/test_video_meta.py b/tests/test_readers/test_video_meta.py
index f5ace763..776c694c 100644
--- a/tests/test_readers/test_video_meta.py
+++ b/tests/test_readers/test_video_meta.py
@@ -6,7 +6,9 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
-from track2data.readers.video_meta import extract_frame
+import pytest
+
+from track2data.readers.video_meta import extract_frame, extract_frame_rgb
# ── real-environment tests (no mocking) ─────────────────────────────────────
#
@@ -115,3 +117,89 @@ def test_returns_none_when_av_open_raises(self, tmp_path: Path) -> None:
result = extract_frame(tmp_path / "corrupt.mp4", frame_index=0)
assert result is None
+
+
+class TestExtractFrameRgb:
+ def test_reports_the_decoded_frame_size(self, tmp_path: Path) -> None:
+ fake_frame = MagicMock(name="frame")
+ image = fake_frame.to_image.return_value
+ image.tobytes.return_value = b"\x01\x02\x03"
+ image.width, image.height = 1080, 1920
+ fake_av = _make_fake_av(frames=[fake_frame])
+
+ with patch.dict(sys.modules, {"av": fake_av}):
+ result = extract_frame_rgb(tmp_path / "video.mp4", frame_index=0)
+
+ assert result == (b"\x01\x02\x03", 1080, 1920)
+
+ def test_returns_none_without_av_or_on_failure(self, tmp_path: Path) -> None:
+ with patch.dict(sys.modules, {"av": None}):
+ assert extract_frame_rgb(tmp_path / "v.mp4") is None
+ failing = _make_fake_av(frames=[], open_side_effect=RuntimeError("bad file"))
+ with patch.dict(sys.modules, {"av": failing}):
+ assert extract_frame_rgb(tmp_path / "v.mp4") is None
+
+
+class TestProbeVideo:
+ def _stream(self, **kw):
+ from fractions import Fraction
+
+ values = {
+ "average_rate": Fraction(25), "guessed_rate": None, "width": 640, "height": 480,
+ "frames": 500,
+ }
+ values.update(kw)
+ stream = MagicMock(name="video_stream")
+ for name, value in values.items():
+ setattr(stream, name, value)
+ return stream
+
+ def _av(self, stream) -> MagicMock:
+ fake_av = _make_fake_av(frames=[])
+ fake_av.open.return_value.streams.video = [stream]
+ return fake_av
+
+ def test_probe_video_without_av_returns_none(self, tmp_path: Path) -> None:
+ from track2data.readers.video_meta import probe_video
+
+ with patch.dict(sys.modules, {"av": None}):
+ assert probe_video(tmp_path / "v.mp4") is None
+
+ def test_probe_video_of_a_missing_file_returns_none(self, tmp_path: Path) -> None:
+ from track2data.readers.video_meta import probe_video
+
+ failing = _make_fake_av(frames=[], open_side_effect=FileNotFoundError("gone"))
+ with patch.dict(sys.modules, {"av": failing}):
+ assert probe_video(tmp_path / "gone.mp4") is None
+
+ def test_probe_video_reads_fps_and_size_from_the_first_video_stream(
+ self, tmp_path: Path
+ ) -> None:
+ from track2data.readers.video_meta import VideoProbe, probe_video
+
+ with patch.dict(sys.modules, {"av": self._av(self._stream())}):
+ assert probe_video(tmp_path / "v.mp4") == VideoProbe(25.0, 640, 480, 500)
+
+ def test_the_frame_count_is_none_when_the_container_has_none(self, tmp_path: Path) -> None:
+ from track2data.readers.video_meta import probe_video
+
+ with patch.dict(sys.modules, {"av": self._av(self._stream(frames=0))}):
+ assert probe_video(tmp_path / "v.mp4").n_frames is None
+
+ def test_a_guessed_rate_is_used_when_there_is_no_average_rate(self, tmp_path: Path) -> None:
+ from fractions import Fraction
+
+ from track2data.readers.video_meta import probe_video
+
+ stream = self._stream(average_rate=None, guessed_rate=Fraction(30000, 1001))
+ with patch.dict(sys.modules, {"av": self._av(stream)}):
+ assert probe_video(tmp_path / "v.mp4").fps == pytest.approx(29.97, abs=0.01)
+
+ @pytest.mark.parametrize(
+ "bad", [{"average_rate": None}, {"width": 0}, {"height": 0}], ids=["fps", "w", "h"]
+ )
+ def test_a_zero_fps_or_zero_size_stream_returns_none(self, tmp_path: Path, bad) -> None:
+ from track2data.readers.video_meta import probe_video
+
+ with patch.dict(sys.modules, {"av": self._av(self._stream(**bad))}):
+ assert probe_video(tmp_path / "v.mp4") is None
diff --git a/tests/test_scan/test_scan.py b/tests/test_scan/test_scan.py
index a7275060..18d94be4 100644
--- a/tests/test_scan/test_scan.py
+++ b/tests/test_scan/test_scan.py
@@ -299,3 +299,55 @@ def test_a_reader_whose_detect_raises_is_simply_not_a_match(
self, toy_readers: None, tiny_real_session: Path
) -> None:
assert "exploding_detect" not in _readers_in(scan([tiny_real_session]))
+
+
+# ── Lenient retry ("Choose software…") ──────────────────────────────────────
+
+
+def _txt_prediction(root: Path) -> Path:
+ import numpy as np
+
+ from tests.support.dlc import random_pose, write_dlc_csv
+
+ root.mkdir(parents=True, exist_ok=True)
+ path = root / "trial1.txt"
+ write_dlc_csv(path, random_pose(20, 1, 3), np.ones((20, 1, 3)), bodyparts=["a", "b", "c"])
+ return path
+
+
+def test_a_scan_result_keeps_its_index_until_released(tmp_path: Path) -> None:
+ result = scan([tmp_path])
+ assert result.index is not None
+ result.release_index()
+ assert result.index is None
+
+
+def test_lenient_scan_offers_a_reader_that_did_not_detect_at_low_confidence(tmp_path: Path) -> None:
+ from track2data.readers.detection import Confidence
+ from track2data.readers.scan import lenient_scan
+
+ path = _txt_prediction(tmp_path / "root")
+ result = scan([tmp_path / "root"])
+ assert result.groups == ()
+ group = lenient_scan(result, "deeplabcut")
+ assert group is not None
+ assert group.best.confidence is Confidence.LOW
+ assert [s.source for s in group.best.sessions] == [path]
+
+
+def test_lenient_scan_of_an_unrelated_reader_returns_none(tmp_path: Path) -> None:
+ from track2data.readers.scan import lenient_scan
+
+ _txt_prediction(tmp_path / "root")
+ result = scan([tmp_path / "root"])
+ assert lenient_scan(result, "idtrackerai") is None
+ assert lenient_scan(result, "no_such_reader") is None
+ result.release_index()
+ assert lenient_scan(result, "deeplabcut") is None
+
+
+def test_only_readers_with_looser_rules_are_listed() -> None:
+ from track2data.readers.scan import lenient_readers
+
+ assert {"deeplabcut", "sleap_analysis"} <= set(lenient_readers())
+ assert "idtrackerai" not in lenient_readers()
diff --git a/tests/test_scripts/test_benchmark_parallel.py b/tests/test_scripts/test_benchmark_parallel.py
index 748e7116..25496691 100644
--- a/tests/test_scripts/test_benchmark_parallel.py
+++ b/tests/test_scripts/test_benchmark_parallel.py
@@ -61,7 +61,7 @@ def test_jitter_varies_session_lengths_and_sessions_are_reused(bench, tmp_path)
def test_manifest_selects_every_individual_and_group_metric(bench, tmp_path) -> None:
folder = bench.write_session(tmp_path / "s", 100, 2, 0)
m = bench.build_manifest([folder], identity_switch=False, zones=True)
- assert len(m.metrics.individual) == 15 and len(m.metrics.group) == 14
+ assert len(m.metrics.individual) == 16 and len(m.metrics.group) == 16
assert len(m.metrics.zone) == 9 and len(m.zones.rois) == 2
switched = bench.build_manifest([folder], identity_switch=True, zones=False)
assert switched.preprocess.identity_switch.enabled
diff --git a/tests/test_ui/test_calibration_screen.py b/tests/test_ui/test_calibration_screen.py
index 10fd0cb6..5c81b7d7 100644
--- a/tests/test_ui/test_calibration_screen.py
+++ b/tests/test_ui/test_calibration_screen.py
@@ -203,6 +203,31 @@ def test_on_calibration_changed_reselects_session_radio_and_widgets(
assert screen._confirm_check.isChecked()
+@pytest.mark.parametrize("unit", ["in", "furlong"])
+def test_a_unit_set_elsewhere_is_shown_and_survives_apply(
+ qtbot, tmp_path: Path, unit: str
+) -> None:
+ """Native 3-D files can be in inches; Apply must not rewrite the label to the first choice."""
+ from track2data.core.models import CalibrationConfig
+ from ui.calibration_screen import CalibrationScreen
+
+ store = _make_store(tmp_path)
+ store.update_calibration(
+ CalibrationConfig(
+ mode="session", length_unit_label=unit, length_unit_confirmed_by_user=True
+ )
+ )
+
+ screen = CalibrationScreen(store)
+ qtbot.addWidget(screen)
+ assert screen._unit_combo.currentText() == unit
+
+ screen._confirm_check.setChecked(False)
+ screen.flush()
+
+ assert store.manifest.calibration.length_unit_label == unit
+
+
# ── per-session readiness list ───────────────────────────────────────────────
diff --git a/tests/test_ui/test_confirm_format_dialog.py b/tests/test_ui/test_confirm_format_dialog.py
index 9eb2f981..8b0e3f76 100644
--- a/tests/test_ui/test_confirm_format_dialog.py
+++ b/tests/test_ui/test_confirm_format_dialog.py
@@ -306,3 +306,121 @@ def test_a_format_it_can_name_but_not_read_is_named_with_what_to_do(self, qtbot)
def test_it_names_the_software_this_version_can_read(self, qtbot) -> None:
dialog = make(qtbot)
assert "idtracker.ai" in dialog.empty_label.text()
+
+
+# ── Read from video ──────────────────────────────────────────────────────────
+
+
+def _fps_size_detection():
+ from track2data.readers.params import ReaderParameter
+
+ params = tuple(
+ ReaderParameter(name=n, label=n, kind=k, required=True)
+ for n, k in (("fps", "float"), ("width_px", "int"), ("height_px", "int"))
+ )
+ return detection("alpha", parameters=params)
+
+
+def test_the_read_from_video_button_is_disabled_when_no_reader_needs_fps_or_size(qtbot) -> None:
+ assert not make(qtbot, (detection("alpha"),)).probe_button.isEnabled()
+ assert make(qtbot, (_fps_size_detection(),)).probe_button.isEnabled()
+
+
+def test_choosing_a_video_fills_the_options_and_enables_add(qtbot, monkeypatch) -> None:
+ from PySide6.QtWidgets import QFileDialog
+
+ from track2data.readers import confirm
+ from track2data.readers.video_meta import VideoProbe
+
+ monkeypatch.setattr(confirm, "probe_video", lambda p: VideoProbe(25.0, 640, 480, 10))
+ monkeypatch.setattr(
+ QFileDialog, "getOpenFileName", staticmethod(lambda *a, **k: ("/data/clip.mp4", ""))
+ )
+ dialog = make(qtbot, (_fps_size_detection(),))
+ assert not dialog.ok_button.isEnabled()
+ dialog.probe_button.click()
+ assert dialog.draft.options == {"fps": 25.0, "width_px": 640, "height_px": 480}
+ assert dialog.draft.option_source("fps") == "video"
+ assert dialog.ok_button.isEnabled()
+
+
+def test_a_video_that_cannot_be_read_warns_and_keeps_the_values(qtbot, monkeypatch) -> None:
+ from PySide6.QtWidgets import QFileDialog, QMessageBox
+
+ from track2data.readers import confirm
+
+ warnings: list[str] = []
+ monkeypatch.setattr(confirm, "probe_video", lambda p: None)
+ monkeypatch.setattr(
+ QFileDialog, "getOpenFileName", staticmethod(lambda *a, **k: ("/data/clip.mp4", ""))
+ )
+ monkeypatch.setattr(
+ QMessageBox, "warning", staticmethod(lambda *a, **k: warnings.append(a[2]) or None)
+ )
+ dialog = make(qtbot, (_fps_size_detection(),))
+ dialog.probe_button.click()
+ assert dialog.draft.options == {}
+ assert warnings and "frame rate" in warnings[0]
+
+
+# ── Choose software… ─────────────────────────────────────────────────────────
+
+
+def _txt_scan(tmp_path: Path) -> ScanResult:
+ import numpy as np
+
+ from tests.support.dlc import random_pose, write_dlc_csv
+ from track2data.readers.scan import scan
+
+ write_dlc_csv(
+ tmp_path / "trial1.txt",
+ random_pose(20, 1, 3),
+ np.ones((20, 1, 3)),
+ bodyparts=["a", "b", "c"],
+ )
+ return scan([tmp_path])
+
+
+def test_the_empty_state_dialog_lists_readers_that_can_try_leniently(qtbot, tmp_path) -> None:
+ dialog = ConfirmFormatDialog(ConfirmDraft(_txt_scan(tmp_path)))
+ qtbot.addWidget(dialog)
+ dialog.show()
+ names = [dialog.lenient_combo.itemData(i) for i in range(dialog.lenient_combo.count())]
+ assert {"deeplabcut", "sleap_analysis"} <= set(names)
+ assert dialog.lenient_combo.isVisible()
+
+
+def test_a_recognised_scan_does_not_offer_the_retry(qtbot) -> None:
+ dialog = make(qtbot, (detection("alpha"),))
+ dialog.show()
+ assert not dialog.lenient_combo.isVisible()
+
+
+def test_choosing_one_populates_the_session_rows(qtbot, tmp_path) -> None:
+ dialog = ConfirmFormatDialog(ConfirmDraft(_txt_scan(tmp_path)))
+ qtbot.addWidget(dialog)
+ dialog.show()
+ dialog.lenient_combo.setCurrentIndex(dialog.lenient_combo.findData("deeplabcut"))
+ dialog.lenient_button.click()
+ assert dialog.draft.reader == "deeplabcut"
+ assert dialog.table.rowCount() == 1
+ assert not dialog.lenient_combo.isVisible()
+
+
+def test_a_reader_that_finds_nothing_says_so_and_stays_empty(qtbot, tmp_path) -> None:
+ dialog = ConfirmFormatDialog(ConfirmDraft(_txt_scan(tmp_path)))
+ qtbot.addWidget(dialog)
+ dialog.show()
+ dialog.lenient_combo.setCurrentIndex(dialog.lenient_combo.findData("sleap_analysis"))
+ dialog.lenient_button.click()
+ assert dialog.draft.is_empty
+ assert "Nothing here could be read" in dialog.empty_label.text()
+
+
+def test_closing_the_dialog_releases_the_scan_index(qtbot, tmp_path) -> None:
+ draft = ConfirmDraft(_txt_scan(tmp_path))
+ dialog = ConfirmFormatDialog(draft)
+ qtbot.addWidget(dialog)
+ assert draft.result.index is not None
+ dialog.reject()
+ assert draft.result.index is None
diff --git a/tests/test_ui/test_export_screen.py b/tests/test_ui/test_export_screen.py
index 7bc986b7..20bb53ce 100644
--- a/tests/test_ui/test_export_screen.py
+++ b/tests/test_ui/test_export_screen.py
@@ -185,6 +185,57 @@ def test_export_uses_project_dir_exports_timestamp_default(
assert ":" not in export_dirs[0].name
+def test_a_second_export_to_a_chosen_directory_asks_before_overwriting(
+ qtbot, tmp_path: Path, tiny_real_session: Path
+) -> None:
+ from ui.export_screen import ExportScreen
+
+ store = _make_ready_store(tmp_path, tiny_real_session)
+ screen = ExportScreen(store)
+ qtbot.addWidget(screen)
+ screen.show()
+ qtbot.waitExposed(screen)
+ screen._checks["csv_long"].setChecked(True)
+ chosen = tmp_path / "chosen"
+ screen._set_output_dir(str(chosen))
+ assert screen._overwrite_checkbox.isVisible() is False # nothing there yet
+
+ with qtbot.waitSignal(store.taskFinished, timeout=15000):
+ screen._run_export()
+ qtbot.waitUntil(lambda: screen._current_task_id is None, timeout=2000)
+
+ assert any(chosen.iterdir())
+ assert screen._overwrite_checkbox.isVisible() is True
+ assert screen._export_btn.isEnabled() is False # until the overwrite is confirmed
+ screen._overwrite_checkbox.setChecked(True)
+ assert screen._export_btn.isEnabled() is True
+
+
+def test_the_default_directory_moves_on_after_an_export(
+ qtbot, tmp_path: Path, tiny_real_session: Path
+) -> None:
+ from ui.export_screen import ExportScreen
+
+ store = _make_ready_store(tmp_path, tiny_real_session)
+ screen = ExportScreen(store)
+ qtbot.addWidget(screen)
+ screen.show()
+ qtbot.waitExposed(screen)
+ screen._checks["csv_long"].setChecked(True)
+ first = screen._resolved_out_dir()
+
+ with qtbot.waitSignal(store.taskFinished, timeout=15000):
+ screen._run_export()
+ qtbot.waitUntil(lambda: screen._current_task_id is None, timeout=2000)
+
+ assert first.exists()
+ # The next export never lands silently in the folder just written: it either gets a fresh
+ # timestamped folder, or (same second) the overwrite guard is armed for the old one.
+ second = screen._resolved_out_dir()
+ assert second != first or screen._overwrite_checkbox.isVisible()
+ assert second.exists() is False or screen._overwrite_checkbox.isVisible()
+
+
# ── _run_export(): guard ────────────────────────────────────────────────────
diff --git a/tests/test_ui/test_metadata_screen.py b/tests/test_ui/test_metadata_screen.py
index 82906b08..8d05b58b 100644
--- a/tests/test_ui/test_metadata_screen.py
+++ b/tests/test_ui/test_metadata_screen.py
@@ -352,3 +352,87 @@ def test_summary_warns_for_identity_free_sessions(qtbot, tmp_path) -> None:
ref = store.manifest.sessions[0].model_copy(update={"identity_free_override": True})
store.update_sessions([ref])
assert "identity-free" in screen._match_label.text()
+
+
+# ── Excel workbooks with several sheets ──────────────────────────────────────
+
+
+def _book(tmp_path: Path, sheets: dict[str, list[list[str]]]) -> Path:
+ openpyxl = pytest.importorskip("openpyxl")
+ wb = openpyxl.Workbook()
+ wb.remove(wb.active)
+ for name, rows in sheets.items():
+ ws = wb.create_sheet(name)
+ for row in rows:
+ ws.append(row)
+ path = tmp_path / "meta.xlsx"
+ wb.save(path)
+ return path
+
+
+_SHEETS = {
+ "Notes": [["note"], ["read me"]],
+ "Trials": [["sid", "cond"], ["s1", "ctrl"]],
+}
+
+
+def test_loading_an_xlsx_previews_it_and_offers_a_sheet_choice(qtbot, tmp_path) -> None:
+ from ui.metadata_screen import MetadataScreen
+
+ screen = MetadataScreen(_store_with_sessions(tmp_path, ["s1"]))
+ qtbot.addWidget(screen)
+ screen.show()
+ screen.load_path(_book(tmp_path, _SHEETS))
+ assert screen._sheet_row.isVisible()
+ assert [screen._sheet_combo.itemText(i) for i in range(screen._sheet_combo.count())] == [
+ "Notes",
+ "Trials",
+ ]
+ assert screen._preview.columnCount() == 1 # the first sheet
+
+
+def test_a_csv_and_a_one_sheet_workbook_offer_no_sheet_choice(qtbot, tmp_path) -> None:
+ from ui.metadata_screen import MetadataScreen
+
+ screen = MetadataScreen(_store_with_sessions(tmp_path, ["s1"]))
+ qtbot.addWidget(screen)
+ screen.show()
+ screen.load_path(_write(tmp_path, ["s1,1,ctrl,1.5"]))
+ assert not screen._sheet_row.isVisible()
+ screen.load_path(_book(tmp_path, {"Only": _SHEETS["Trials"]}))
+ assert not screen._sheet_row.isVisible()
+ assert screen._store.manifest.metadata_source.sheet is None
+
+
+def test_choosing_a_second_sheet_persists_it_on_the_store(qtbot, tmp_path) -> None:
+ from ui.metadata_screen import MetadataScreen
+
+ store = _store_with_sessions(tmp_path, ["s1"])
+ screen = MetadataScreen(store)
+ qtbot.addWidget(screen)
+ screen.show()
+ screen.load_path(_book(tmp_path, _SHEETS))
+ screen._sheet_combo.setCurrentIndex(1)
+ assert store.manifest.metadata_source.sheet == "Trials"
+ assert screen._preview.columnCount() == 2
+ assert screen._combos["session_id"].findText("sid") >= 0 # the Trials columns are offered
+
+
+def test_reopened_project_restores_the_chosen_sheet(qtbot, tmp_path) -> None:
+ from track2data.core.hashing import file_sha256
+ from track2data.core.models import MappingRule, MetadataSource
+ from ui.metadata_screen import MetadataScreen
+
+ path = _book(tmp_path, _SHEETS)
+ store = _store_with_sessions(tmp_path, ["s1"])
+ screen = MetadataScreen(store)
+ qtbot.addWidget(screen)
+ screen.show()
+ store.update_metadata_source(
+ MetadataSource(path=path, sha256=file_sha256(path), sheet="Trials")
+ )
+ store.update_mapping(MappingRule(rules={"session_id": "sid", "treatment": "cond"}))
+ assert screen._sheet_row.isVisible()
+ assert screen._sheet_combo.currentText() == "Trials"
+ assert screen._combos["treatment"].currentText() == "cond"
+ assert "1 of 1 sessions matched" in screen._match_label.text()
diff --git a/tests/test_ui/test_metrics_screen.py b/tests/test_ui/test_metrics_screen.py
index 9767c1c9..a79e0ed9 100644
--- a/tests/test_ui/test_metrics_screen.py
+++ b/tests/test_ui/test_metrics_screen.py
@@ -864,9 +864,10 @@ def test_social_preset_selects_group_metrics_and_everything_selects_all(qtbot) -
screen.apply_preset("All metrics")
screen.flush()
m = store.manifest.metrics
- # IL-15, IL-16, IL-17 and GL-16 are greyed out in a 2-D project, so the preset skips them
+ # IL-15..IL-18 and GL-16, GL-18, GL-19 are greyed out in a 2-D project, so the preset skips them
assert len(m.individual) == 12 and len(m.group) == 13 and len(m.zone) == 9
- assert {"IL-15", "IL-16", "IL-17"}.isdisjoint(m.individual) and "GL-16" not in m.group
+ assert {"IL-15", "IL-16", "IL-17", "IL-18"}.isdisjoint(m.individual)
+ assert {"GL-16", "GL-18", "GL-19"}.isdisjoint(m.group)
def test_all_metrics_preset_includes_the_3d_metrics_in_a_3d_scalar_project(qtbot) -> None:
@@ -885,7 +886,8 @@ def test_all_metrics_preset_includes_the_3d_metrics_in_a_3d_scalar_project(qtbot
screen.apply_preset("All metrics")
screen.flush()
m = store.manifest.metrics
- assert {"IL-16", "IL-17"} <= set(m.individual) and "GL-16" in m.group
+ assert {"IL-16", "IL-17", "IL-18"} <= set(m.individual)
+ assert {"GL-16", "GL-18", "GL-19"} <= set(m.group)
assert screen.current_preset_name() == "All metrics"
@@ -904,11 +906,11 @@ def test_counter_shows_selected_of_total_by_level(qtbot) -> None:
screen = MetricsScreen(store=_make_store())
qtbot.addWidget(screen)
- assert "0 / 38" in screen._counter.text()
+ assert "0 / 41" in screen._counter.text()
screen._ind_table.item(_row_for_id(screen._ind_table, "IL-1"), 0).setCheckState(
Qt.CheckState.Checked
)
- assert "1 / 38" in screen._counter.text()
+ assert "1 / 41" in screen._counter.text()
assert "1 individual" in screen._counter.text()
@@ -1256,3 +1258,31 @@ def test_preset_leaves_identity_needing_rows_unticked_when_every_session_is_iden
screen.flush()
assert store.manifest.metrics.individual == []
assert screen.current_preset_name() == "None"
+
+
+def test_thigmotaxis_preset_leaves_greyed_side_view_rows_unticked(qtbot) -> None:
+ from ui.metrics_screen import MetricsScreen
+
+ store = _make_store()
+ _set_view(store, "side")
+ screen = MetricsScreen(store=store)
+ qtbot.addWidget(screen)
+
+ for metric_id in ("IL-3", "IL-14"):
+ assert not _is_enabled(screen._ind_table.item(_row_for_id(screen._ind_table, metric_id), 0))
+ screen.apply_preset("Thigmotaxis & space use")
+ checked = set(screen._checked_ids(screen._ind_table))
+ assert not {"IL-3", "IL-14"} & checked
+
+
+def test_il3_and_il14_are_enabled_for_an_unset_or_top_view(qtbot) -> None:
+ from ui.metrics_screen import MetricsScreen
+
+ for view in ("unknown", "top"):
+ store = _make_store()
+ _set_view(store, view)
+ screen = MetricsScreen(store=store)
+ qtbot.addWidget(screen)
+ for metric_id in ("IL-3", "IL-14"):
+ item = screen._ind_table.item(_row_for_id(screen._ind_table, metric_id), 0)
+ assert _is_enabled(item)
diff --git a/tests/test_ui/test_panel_preview.py b/tests/test_ui/test_panel_preview.py
index 06b4dcbe..1ed42e08 100644
--- a/tests/test_ui/test_panel_preview.py
+++ b/tests/test_ui/test_panel_preview.py
@@ -18,7 +18,7 @@
)
from track2data.core.models import PanelRect
-from ui.widgets import panel_preview as pp
+from ui.widgets import video_frame as vf
from ui.widgets.panel_preview import PanelPreview
SIZE = (40.0, 20.0)
@@ -55,32 +55,32 @@ def _tracks(n_frames: int = 10, n_animals: int = 2) -> np.ndarray:
def test_background_image_wins(view, bg_png, video, monkeypatch) -> None:
- monkeypatch.setattr(pp, "extract_frame", lambda p, i=0: bytes(40 * 20 * 3))
+ monkeypatch.setattr(vf, "extract_frame_rgb", lambda p, i=0: (bytes(40 * 20 * 3), 40, 20))
view.set_source(SIZE, bg_png, video, _tracks())
assert view.backdrop_kind == "image"
assert any(isinstance(i, QGraphicsPixmapItem) for i in view.scene().items())
def test_video_frame_when_no_background(view, video, monkeypatch) -> None:
- monkeypatch.setattr(pp, "extract_frame", lambda p, i=0: bytes(40 * 20 * 3))
+ monkeypatch.setattr(vf, "extract_frame_rgb", lambda p, i=0: (bytes(40 * 20 * 3), 40, 20))
view.set_source(SIZE, None, video, _tracks())
assert view.backdrop_kind == "video"
def test_missing_background_file_falls_to_video(view, tmp_path, video, monkeypatch) -> None:
- monkeypatch.setattr(pp, "extract_frame", lambda p, i=0: bytes(40 * 20 * 3))
+ monkeypatch.setattr(vf, "extract_frame_rgb", lambda p, i=0: (bytes(40 * 20 * 3), 40, 20))
view.set_source(SIZE, tmp_path / "gone.png", video, None)
assert view.backdrop_kind == "video"
def test_wrong_length_frame_falls_to_tracks(view, video, monkeypatch) -> None:
- monkeypatch.setattr(pp, "extract_frame", lambda p, i=0: bytes(10))
+ monkeypatch.setattr(vf, "extract_frame_rgb", lambda p, i=0: (bytes(10), 40, 20))
view.set_source(SIZE, None, video, _tracks())
assert view.backdrop_kind == "tracks"
def test_none_frame_falls_to_tracks(view, video, monkeypatch) -> None:
- monkeypatch.setattr(pp, "extract_frame", lambda p, i=0: None)
+ monkeypatch.setattr(vf, "extract_frame_rgb", lambda p, i=0: None)
view.set_source(SIZE, None, video, _tracks())
assert view.backdrop_kind == "tracks"
@@ -89,7 +89,7 @@ def test_raising_extract_falls_to_tracks(view, video, monkeypatch) -> None:
def boom(p, i=0):
raise RuntimeError("no codec")
- monkeypatch.setattr(pp, "extract_frame", boom)
+ monkeypatch.setattr(vf, "extract_frame_rgb", boom)
view.set_source(SIZE, None, video, _tracks())
assert view.backdrop_kind == "tracks"
@@ -98,7 +98,7 @@ def test_missing_video_is_not_read(view, tmp_path, monkeypatch) -> None:
def boom(p, i=0):
raise AssertionError("must not be called")
- monkeypatch.setattr(pp, "extract_frame", boom)
+ monkeypatch.setattr(vf, "extract_frame_rgb", boom)
view.set_source(SIZE, None, tmp_path / "nope.mp4", _tracks())
assert view.backdrop_kind == "tracks"
diff --git a/tests/test_ui/test_preview_screen.py b/tests/test_ui/test_preview_screen.py
index e09e044f..59d71301 100644
--- a/tests/test_ui/test_preview_screen.py
+++ b/tests/test_ui/test_preview_screen.py
@@ -634,8 +634,12 @@ def test_load_trajectory_data_fills_crop_from_the_refs_panel(
store = _trajectory_store(tmp_path, tiny_real_session)
assert load_trajectory_data(store.manifest, tiny_real_session.name, None).crop is None
- panel = PanelRect(x=0, y=0, width=10, height=10)
+ from track2data.api import Engine
+
ref = store.manifest.sessions[0]
+ video = Engine(store.manifest).import_ref(ref).video
+ # A panel the animals are in (the whole frame); one nobody is in is refused outright.
+ panel = PanelRect(x=0, y=0, width=video.width_px, height=video.height_px)
store.manifest.sessions[0] = ref.model_copy(update={"panel": panel})
data = load_trajectory_data(store.manifest, tiny_real_session.name, None)
assert data.crop == panel
diff --git a/tests/test_ui/test_processing_screen.py b/tests/test_ui/test_processing_screen.py
index 9232954f..a14b448a 100644
--- a/tests/test_ui/test_processing_screen.py
+++ b/tests/test_ui/test_processing_screen.py
@@ -316,6 +316,27 @@ def test_workers_segments_drive_the_spin_box_and_the_cli_command(qtbot) -> None:
assert screen.cli_command().endswith("--workers 2")
+def test_every_flag_in_the_shown_command_exists_on_the_cli(qtbot, tmp_path) -> None:
+ """The box promises the same run, headless: a flag `track2data run` rejects breaks that."""
+ import shlex
+
+ from track2data.cli import run as run_command
+ from ui.processing_screen import ProcessingScreen
+ from ui.store.project_store import ProjectStore
+
+ store = ProjectStore()
+ store.new_project("my project", tmp_path)
+ screen = ProcessingScreen(store)
+ qtbot.addWidget(screen)
+ known = {opt for param in run_command.params for opt in getattr(param, "opts", [])}
+ tokens = shlex.split(screen.cli_command())
+ assert tokens[:2] == ["track2data", "run"]
+ assert tokens[2] == "my project.t2d.json" # one argument, even with a space in the name
+ flags = [t for t in tokens if t.startswith("--")]
+ assert "--workers" in flags
+ assert set(flags) <= known
+
+
def test_clicking_a_setup_check_asks_to_navigate_to_its_page(qtbot) -> None:
from PySide6.QtWidgets import QPushButton
diff --git a/tests/test_ui/test_project_store.py b/tests/test_ui/test_project_store.py
index 182f448b..825d5da5 100644
--- a/tests/test_ui/test_project_store.py
+++ b/tests/test_ui/test_project_store.py
@@ -1453,3 +1453,21 @@ def test_fusion_survives_a_panel_change(qtbot, tmp_path: Path, store) -> None:
kept = store.manifest.view_pairs[0]
assert kept.fish_map == {} and not kept.same_ids
assert kept.fusion == _fusion()
+
+
+def test_removing_a_session_drops_its_video_override(store, tmp_path: Path) -> None:
+ """A later session that gets the same id must not inherit the removed one's video."""
+ video = tmp_path / "located.avi"
+ video.write_bytes(b"x")
+ a = SessionRef(session_id="trial1", folder=tmp_path / "trial1", sha256="")
+ b = SessionRef(session_id="trial2", folder=tmp_path / "trial2", sha256="")
+ store.update_sessions([a, b])
+ store.set_video_path("trial1", video)
+ store.set_video_path("trial2", video)
+ assert set(store.manifest.video_overrides) == {"trial1", "trial2"}
+
+ store.update_sessions([b])
+ assert set(store.manifest.video_overrides) == {"trial2"}
+
+ store.update_sessions([b, a]) # a new folder that is also called trial1
+ assert "trial1" not in store.manifest.video_overrides
diff --git a/tests/test_ui/test_zone_canvas.py b/tests/test_ui/test_zone_canvas.py
index 1eef423d..f44970ba 100644
--- a/tests/test_ui/test_zone_canvas.py
+++ b/tests/test_ui/test_zone_canvas.py
@@ -500,3 +500,112 @@ def test_load_session_without_crop_keeps_the_whole_backdrop(qtbot, tmp_path: Pat
canvas.load_session(_bg(tmp_path), None)
rect = canvas._gscene.sceneRect()
assert (rect.width(), rect.height()) == (400, 200)
+
+
+# ── video first frame as the backdrop ────────────────────────────────────────
+
+
+@pytest.fixture
+def fake_video(tmp_path: Path) -> Path:
+ path = tmp_path / "v.mp4"
+ path.write_bytes(b"x")
+ return path
+
+
+def test_zone_canvas_uses_the_video_first_frame_when_there_is_no_background_image(
+ qtbot, fake_video: Path, monkeypatch
+) -> None:
+ from PySide6.QtWidgets import QGraphicsPixmapItem
+
+ from ui.widgets import video_frame
+
+ monkeypatch.setattr(
+ video_frame, "extract_frame_rgb", lambda p, i=0: (bytes(40 * 20 * 3), 40, 20)
+ )
+ canvas = ZoneCanvas()
+ qtbot.addWidget(canvas)
+
+ canvas.load_session(None, None, frame_size=(40, 20), video_path=fake_video)
+
+ assert any(isinstance(i, QGraphicsPixmapItem) for i in canvas.scene().items())
+ assert (canvas.scene().sceneRect().width(), canvas.scene().sceneRect().height()) == (40, 20)
+
+
+def test_a_video_frame_of_the_wrong_size_is_rejected_and_the_canvas_stays_blank(
+ qtbot, fake_video: Path, monkeypatch
+) -> None:
+ from PySide6.QtWidgets import QGraphicsPixmapItem
+
+ from ui.widgets import video_frame
+
+ monkeypatch.setattr(
+ video_frame, "extract_frame_rgb", lambda p, i=0: (bytes(30 * 20 * 3), 30, 20)
+ )
+ canvas = ZoneCanvas()
+ qtbot.addWidget(canvas)
+
+ canvas.load_session(None, None, frame_size=(40, 20), video_path=fake_video)
+
+ assert not any(isinstance(i, QGraphicsPixmapItem) for i in canvas.scene().items())
+ assert canvas.scene().sceneRect().width() == 40 # the blank scene is still the frame's size
+
+
+def test_a_transposed_frame_with_the_same_byte_count_is_rejected(
+ qtbot, fake_video: Path, monkeypatch
+) -> None:
+ """1080x1920 decoded against a declared 1920x1080 has the same w * h * 3 bytes."""
+ from PySide6.QtWidgets import QGraphicsPixmapItem
+
+ from ui.widgets import video_frame
+
+ monkeypatch.setattr(
+ video_frame, "extract_frame_rgb", lambda p, i=0: (bytes(40 * 20 * 3), 20, 40)
+ )
+ canvas = ZoneCanvas()
+ qtbot.addWidget(canvas)
+
+ canvas.load_session(None, None, frame_size=(40, 20), video_path=fake_video)
+
+ assert not any(isinstance(i, QGraphicsPixmapItem) for i in canvas.scene().items())
+
+
+def test_a_cropped_panel_never_falls_back_to_the_video_frame(
+ qtbot, fake_video: Path, monkeypatch
+) -> None:
+ from track2data.core.models import PanelRect
+ from ui.widgets import video_frame
+
+ def boom(p, i=0):
+ raise AssertionError("a panel session must not read the whole-video frame")
+
+ monkeypatch.setattr(video_frame, "extract_frame_rgb", boom)
+ canvas = ZoneCanvas()
+ qtbot.addWidget(canvas)
+
+ canvas.load_session(
+ None, None, frame_size=(40, 20), crop=PanelRect(x=0, y=0, width=20, height=20),
+ video_path=fake_video,
+ )
+
+
+def test_a_background_image_wins_over_the_video_frame(
+ qtbot, tmp_path: Path, fake_video: Path, monkeypatch
+) -> None:
+ from PySide6.QtGui import QImage
+
+ from ui.widgets import video_frame
+
+ def boom(p, i=0):
+ raise AssertionError("the background image must be used first")
+
+ monkeypatch.setattr(video_frame, "extract_frame_rgb", boom)
+ png = tmp_path / "bg.png"
+ image = QImage(20, 10, QImage.Format.Format_RGB32)
+ image.fill(0)
+ image.save(str(png))
+ canvas = ZoneCanvas()
+ qtbot.addWidget(canvas)
+
+ canvas.load_session(png, None, frame_size=(40, 20), video_path=fake_video)
+
+ assert canvas.scene().sceneRect().width() == 20
diff --git a/tests/test_ui/test_zones_screen.py b/tests/test_ui/test_zones_screen.py
index 00db36ce..2108cef9 100644
--- a/tests/test_ui/test_zones_screen.py
+++ b/tests/test_ui/test_zones_screen.py
@@ -796,3 +796,23 @@ def test_zones_screen_passes_the_sessions_panel_to_the_canvas(
screen._session_combo.setCurrentText("session_a")
screen._refresh_canvas()
assert calls[-1] == panel
+
+
+def test_zones_screen_passes_the_session_video_path_to_the_canvas(
+ qtbot, tmp_path: Path, monkeypatch
+) -> None:
+ from ui.widgets.zone_canvas import ZoneCanvas
+ from ui.zones_screen import ZonesScreen
+
+ store = _make_store(tmp_path)
+ video = tmp_path / "v.mp4"
+ _add_session_with_facts(store, "session_a", tmp_path, video_path=video)
+ calls = []
+ monkeypatch.setattr(
+ ZoneCanvas, "load_session", lambda self, *a, **k: calls.append(k.get("video_path"))
+ )
+ screen = ZonesScreen(store)
+ qtbot.addWidget(screen)
+ screen._session_combo.setCurrentText("session_a")
+ screen._refresh_canvas()
+ assert calls[-1] == video
diff --git a/tests/test_views/test_panels.py b/tests/test_views/test_panels.py
index 7434bd60..689fa8b1 100644
--- a/tests/test_views/test_panels.py
+++ b/tests/test_views/test_panels.py
@@ -95,7 +95,10 @@ def test_identity_free_has_no_fifty_percent_rule():
inside, outside = [150, 80], [10, 10]
raw = [[inside]] + [[outside]] * 9
assert apply_panel(_session(raw, stable=False), PANEL).n_animals == 1
- assert apply_panel(_session(raw, stable=True), PANEL).n_animals == 0
+ # with stable identities the 50% rule applies: no animal reaches it, and a session of zero
+ # animals is refused rather than handed on
+ with pytest.raises(ValueError, match="No animal has enough"):
+ apply_panel(_session(raw, stable=True), PANEL)
def test_setup_points_and_roi_shift():
@@ -255,8 +258,8 @@ def test_half_open_bounds():
def test_zero_kept_animals():
s = _session([[[10, 10]]] * 3)
assert panel_coverage(s, PANEL)[0].share_inside == 0.0
- out = apply_panel(s, PANEL)
- assert out.n_animals == 0 and out.raw_xy.shape == (3, 0, 2)
+ with pytest.raises(ValueError, match="No animal has enough"):
+ apply_panel(s, PANEL)
def test_mismatched_per_animal_data_is_dropped():
@@ -342,3 +345,11 @@ def test_left_out_animals_are_logged(caplog):
with caplog.at_level(logging.WARNING, logger="track2data.views.panels"):
apply_panel(_session(raw, stable=False), PANEL)
assert caplog.records == []
+
+
+def test_a_panel_nobody_is_in_is_refused_for_either_identity_mode():
+ outside = [10, 10]
+ raw = [[outside, outside]] * 4
+ for stable in (True, False):
+ with pytest.raises(ValueError, match="No animal has enough"):
+ apply_panel(_session(raw, stable=stable), PANEL)
diff --git a/tests/test_zones/test_io.py b/tests/test_zones/test_io.py
index 630143f8..e207f789 100644
--- a/tests/test_zones/test_io.py
+++ b/tests/test_zones/test_io.py
@@ -82,6 +82,59 @@ def test_csv_file_has_header(tmp_path: Path) -> None:
assert "roi_name" in first_line or "name" in first_line
+def test_csv_roundtrip_keeps_holes_apart_from_their_outline(tmp_path: Path) -> None:
+ """An arena and its exclusion hole share a name; merging them made the hole additive."""
+ outline = [(0, 0), (100, 0), (100, 100), (0, 100)]
+ hole = [(40, 40), (60, 40), (60, 60), (40, 60)]
+ zs = ZoneSet(
+ rois=[
+ ROI(name="arena", level="main", vertices=outline, sign="+"),
+ ROI(name="arena", level="main", vertices=hole, sign="-"),
+ ROI(name="arena", level="main", vertices=[(200, 0), (300, 0), (300, 50)], sign="+"),
+ ],
+ source_width_px=1920,
+ source_height_px=1080,
+ )
+ path = tmp_path / "zones.csv"
+ save_zones_csv(zs, path)
+ loaded = load_zones_csv(path)
+ assert [(r.name, r.sign, len(r.vertices)) for r in loaded.rois] == [
+ ("arena", "+", 4),
+ ("arena", "-", 4),
+ ("arena", "+", 3),
+ ]
+ assert loaded.rois[1].vertices == [(float(x), float(y)) for x, y in hole]
+ assert (loaded.source_width_px, loaded.source_height_px) == (1920, 1080)
+
+
+def test_csv_without_source_size_roundtrips_to_none(tmp_path: Path) -> None:
+ zs = ZoneSet(rois=[ROI(name="A", vertices=[(0, 0), (1, 0), (1, 1)])])
+ path = tmp_path / "zones.csv"
+ save_zones_csv(zs, path)
+ loaded = load_zones_csv(path)
+ assert loaded.source_width_px is None and loaded.source_height_px is None
+
+
+def test_legacy_four_column_csv_still_loads(tmp_path: Path) -> None:
+ path = tmp_path / "old.csv"
+ path.write_text(
+ "roi_name,level,vertex_x,vertex_y\nA,main,0,0\nA,main,1,0\nA,main,1,1\n",
+ encoding="utf-8",
+ )
+ loaded = load_zones_csv(path)
+ assert len(loaded.rois) == 1
+ assert loaded.rois[0].sign == "+" and len(loaded.rois[0].vertices) == 3
+
+
+def test_csv_with_a_bad_sign_is_refused(tmp_path: Path) -> None:
+ path = tmp_path / "bad.csv"
+ path.write_text(
+ "roi_name,level,vertex_x,vertex_y,sign\nA,main,0,0,x\n", encoding="utf-8"
+ )
+ with pytest.raises(ValueError, match="sign"):
+ load_zones_csv(path)
+
+
def test_load_csv_missing_file_raises(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
load_zones_csv(tmp_path / "nonexistent.csv")
diff --git a/tests/test_zones/test_orientation.py b/tests/test_zones/test_orientation.py
index ffc247d7..05186062 100644
--- a/tests/test_zones/test_orientation.py
+++ b/tests/test_zones/test_orientation.py
@@ -128,3 +128,18 @@ def test_flip_orientation_idempotent_double_flip() -> None:
double_flipped = flip_orientation(flip_orientation(zs))
assert double_flipped.orientation_tag == "FT"
assert list(double_flipped.rois[0].vertices) == vertices
+
+
+def test_flip_orientation_keeps_hole_signs_and_source_size() -> None:
+ zs = ZoneSet(
+ rois=[
+ ROI(name="arena", level="main", vertices=[(0, 0), (10, 0), (10, 10)], sign="+"),
+ ROI(name="arena", level="main", vertices=[(2, 2), (4, 2), (4, 4)], sign="-"),
+ ],
+ orientation_tag="FT",
+ source_width_px=640,
+ source_height_px=480,
+ )
+ flipped = flip_orientation(zs)
+ assert [r.sign for r in flipped.rois] == ["+", "-"]
+ assert (flipped.source_width_px, flipped.source_height_px) == (640, 480)
diff --git a/track2data/api.py b/track2data/api.py
index c353125b..1971d963 100644
--- a/track2data/api.py
+++ b/track2data/api.py
@@ -327,7 +327,7 @@ def _metadata_join(self) -> Any:
from track2data.metadata.loader import load
from track2data.metadata.mapping import apply_mapping
- raw = load(src.path)
+ raw = load(src.path, src.sheet)
mapped = apply_mapping(raw, rule)
session_ids = [ref.session_id for ref in self._manifest.sessions]
return match(session_ids, mapped, rule)
@@ -925,13 +925,19 @@ def _import_settings_fingerprint(self, ref: SessionRef) -> dict[str, Any]:
"""
m = self._manifest
override = m.video_overrides.get(ref.session_id)
- return {
+ fingerprint: dict[str, Any] = {
"allow_pickle_trajectories": m.security.allow_pickle_trajectories,
"blob_diagnostics": m.blob_diagnostics,
"video_override": (
None if override is None else [str(override), Path(override).exists()]
),
}
+ # The user's identity-free answer decides whether gaps may be bridged across tracking
+ # intervals during preprocessing. Only when set, so existing keys stay valid.
+ identity_free = self._identity_free_override(ref.session_id)
+ if identity_free is not None:
+ fingerprint["identity_free_override"] = identity_free
+ return fingerprint
def preprocess_ref(self, ref: SessionRef) -> PreprocessedSession:
"""Preprocess the session a manifest entry describes, reusing/filling the cache
diff --git a/track2data/cli.py b/track2data/cli.py
index 30222f8f..65be7df8 100644
--- a/track2data/cli.py
+++ b/track2data/cli.py
@@ -125,8 +125,14 @@ def restore() -> None:
help="Exporter(s) to use. Repeat for multiple.")
@click.option("--cache-dir", default=None,
help="Reuse preprocessed sessions from this directory (off by default).")
+@click.option("--workers", "-w", "n_workers", type=click.IntRange(min=1), default=1,
+ show_default=True, help="Sessions to process in parallel.")
def run(
- project: str, out_dir: str | None, exporters: tuple[str, ...], cache_dir: str | None
+ project: str,
+ out_dir: str | None,
+ exporters: tuple[str, ...],
+ cache_dir: str | None,
+ n_workers: int,
) -> None:
"""Run the full pipeline for a project manifest.
@@ -170,7 +176,9 @@ def run(
click.echo(f"Running project '{manifest.project_name}' ({size})…")
try:
- result = engine.run(out_path, exporters=exporter_list, plan=plan)
+ result = engine.run(
+ out_path, exporters=exporter_list, plan=plan, n_workers=n_workers
+ )
except Exception as exc:
click.echo(f"[error] Pipeline failed: {exc}", err=True)
logger.exception("Pipeline failed")
@@ -410,6 +418,11 @@ def new(name: str, out: str | None) -> None:
sys.exit(1)
+def _recognised_by(draft: ConfirmDraft, reader: str) -> bool:
+ """Whether the scan already offers *reader* for some group."""
+ return any(d.reader == reader for group in draft.groups for d in group.detections)
+
+
# ── list-readers ──────────────────────────────────────────────────────────────
_VERIFICATION_NOTE = {
@@ -645,12 +658,22 @@ def add(
result = Engine.scan([Path(r) for r in roots], budget=budget)
_echo_scan_header(result)
draft = ConfirmDraft(result, existing=manifest.sessions)
+ lenient = False
+ if reader is not None and not _recognised_by(draft, reader):
+ # The user named software the scan did not recognise: try its looser rules, as the
+ # app's "Choose software" does. The sessions still go through the reader's own read.
+ lenient = draft.offer_reader(reader)
+ if lenient:
+ click.echo(
+ f"Nothing matched {reader} exactly; offering files it can read under looser rules."
+ )
if draft.is_empty:
_echo_nothing_found(result)
sys.exit(1)
try:
- draft.select_group(group_number - 1)
+ if not lenient:
+ draft.select_group(group_number - 1)
if reader is not None:
draft.select_reader(reader)
shared = [p for p in draft.parameters if p.scope == "group"]
diff --git a/track2data/core/models.py b/track2data/core/models.py
index c64dee21..5e0c1f28 100644
--- a/track2data/core/models.py
+++ b/track2data/core/models.py
@@ -701,6 +701,18 @@ def is_identity_free(self) -> bool:
class MetadataSource(BaseModel):
path: Path
sha256: str
+ # Which sheet of an Excel workbook holds the metadata; None = the first sheet (and every CSV).
+ sheet: str | None = None
+
+ @model_serializer(mode="wrap")
+ def _omit_unset_sheet(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
+ # A plain field would write ``"sheet": null`` and change ``project_hash`` (a dump of the
+ # whole manifest) for every project that already has a metadata source. Left out while
+ # unset, the saved JSON and the hash stay byte-identical until a sheet is chosen.
+ data: dict[str, Any] = handler(self)
+ if data.get("sheet") is None:
+ data.pop("sheet", None)
+ return data
class MappingRule(BaseModel):
diff --git a/track2data/core/session_consistency.py b/track2data/core/session_consistency.py
index 140252e5..88c68cb9 100644
--- a/track2data/core/session_consistency.py
+++ b/track2data/core/session_consistency.py
@@ -93,6 +93,10 @@ class SessionSummary:
# False for a native 3-D session: it has no image frame, so width_px/height_px (0) say
# nothing and must not be compared or reported as a resolution.
has_pixel_frame: bool = True
+ # Share of (frame, animal) slots with no position. Mostly-empty slots mean the tracker handed
+ # over fragments, not continuous animals (a Ctrax file with one slot per track id). None =
+ # not computed, which never raises the fragmentation advisory.
+ nan_slot_fraction: float | None = None
@property
def duration_s(self) -> float:
@@ -143,9 +147,20 @@ def from_session(
length_calibration_n=cal_n,
length_calibration_rel_sd=cal_rel_sd,
has_pixel_frame=session.has_pixel_frame,
+ nan_slot_fraction=_nan_slot_fraction(session),
)
+def _nan_slot_fraction(session: Session) -> float | None:
+ """The share of (frame, animal) slots with no x position, or None for an empty array."""
+ import numpy as np
+
+ xy = session.raw_xy
+ if xy.size == 0:
+ return None
+ return float(np.isnan(xy[..., 0]).mean())
+
+
def _group_by(
summaries: Sequence[SessionSummary], key: Any
) -> dict[Any, list[str]]:
diff --git a/track2data/core/session_identity.py b/track2data/core/session_identity.py
new file mode 100644
index 00000000..d25755f8
--- /dev/null
+++ b/track2data/core/session_identity.py
@@ -0,0 +1,50 @@
+"""When two project entries are the same part of the same recording.
+
+One rule shared by the import dialog (``ConfirmDraft``) and the project store, so the dialog never
+offers what the store would refuse, nor leaves out what the store would accept.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from track2data.core.models import SessionRef
+
+
+def resolved(path: Path) -> Path:
+ """*path* made absolute when the file system allows it, as given otherwise."""
+ try:
+ return Path(path).resolve()
+ except OSError:
+ return Path(path)
+
+
+def session_selector(ref: SessionRef) -> tuple[tuple[str, str], ...]:
+ """The options that pick *which* session in a file (an arena), not how to read it."""
+ # Imported here: ``track2data.readers`` imports ``track2data.core``.
+ from track2data.readers import find_reader
+
+ reader = find_reader(ref.reader) if ref.reader else None
+ names = {p.name for p in reader.parameters if p.scope == "session"} if reader else set()
+ return tuple(sorted((k, repr(v)) for k, v in ref.reader_options.items() if k in names))
+
+
+def same_session_part(candidate: SessionRef, existing: SessionRef) -> bool:
+ """Whether *candidate* is a session the project already holds as *existing*.
+
+ The same place, read by the same reader, picking the same arena, and covering the same part
+ of the video. The rule is deliberately not symmetric: a legacy *existing* entry with no saved
+ reader counts as any reader, but a *candidate* with no reader matches only an existing entry
+ with the same reader or none.
+
+ The same folder under another reader is a different session (another reader could read the
+ same files into different numbers). So is the same folder with another panel: it is another
+ part of the video, which is why a folder that was split into panels is offered again as a
+ whole-frame session when rescanned.
+ """
+ return (
+ resolved(candidate.folder) == resolved(existing.folder)
+ and (existing.reader is None or existing.reader == candidate.reader)
+ and session_selector(candidate) == session_selector(existing)
+ and existing.panel == candidate.panel
+ )
diff --git a/track2data/exporters/_merge.py b/track2data/exporters/_merge.py
index 889313fb..fe31808e 100644
--- a/track2data/exporters/_merge.py
+++ b/track2data/exporters/_merge.py
@@ -16,13 +16,18 @@
_DROP_COLS = ("metric_id",)
-def _same_values(left: pd.DataFrame, right: pd.DataFrame, keys: list[str], col: str) -> bool:
- """True if *col* agrees between the frames on every shared key (NaN == NaN)."""
+def _conflicts(left: pd.DataFrame, right: pd.DataFrame, keys: list[str], col: str) -> bool:
+ """True if *col* disagrees between the frames on some shared key (NaN == NaN).
+
+ Frames that share no key rows cannot disagree, so that is False -- but it also means the
+ columns carry different rows' values, which is why the caller coalesces them rather than
+ dropping one side.
+ """
both = left[[*keys, col]].merge(
right[[*keys, col]], on=keys, how="inner", suffixes=("_l", "_r")
)
a, b = both[f"{col}_l"], both[f"{col}_r"]
- return bool(((a == b) | (a.isna() & b.isna())).all())
+ return not bool(((a == b) | (a.isna() & b.isna())).all())
def merge_metric_frames(metrics: dict[str, pd.DataFrame]) -> pd.DataFrame:
@@ -42,7 +47,14 @@ def merge_metric_frames(metrics: dict[str, pd.DataFrame]) -> pd.DataFrame:
result = pd.concat([result, other], axis=1)
continue
dup = [c for c in other.columns if c in result.columns and c not in keys]
- same = [c for c in dup if _same_values(result, other, keys, c)]
- result = result.merge(other.drop(columns=same), on=keys, how="outer")
+ agree = [c for c in dup if not _conflicts(result, other, keys, c)]
+ # An agreeing column is kept once, but filled from *other* on the rows only it has:
+ # dropping it would leave those rows empty (e.g. the bin boundaries of a binned
+ # frame merged after a whole-session one).
+ renamed = {c: f"{c}__other" for c in agree}
+ result = result.merge(other.rename(columns=renamed), on=keys, how="outer")
+ for c, tmp in renamed.items():
+ result[c] = result[c].where(result[c].notna(), result[tmp])
+ result = result.drop(columns=tmp)
sort_keys = [k for k in KEY_COLS if k in result.columns]
return result.sort_values(sort_keys).reset_index(drop=True) if sort_keys else result
diff --git a/track2data/exporters/readme.py b/track2data/exporters/readme.py
index dd08ca0b..1ceac043 100644
--- a/track2data/exporters/readme.py
+++ b/track2data/exporters/readme.py
@@ -211,7 +211,7 @@ def _native_3d_lines(p: SessionProvenance) -> list[str]:
*(
[
f"| Samples outside the water column | {n['outside_samples']} "
- "(left out of IL-15, IL-16, IL-17 and GL-16, never clipped) |"
+ "(left out of IL-15, IL-16, IL-17, IL-18 and GL-16, never clipped) |"
]
if n.get("outside_samples") is not None
else []
diff --git a/track2data/exporters/schema.py b/track2data/exporters/schema.py
index 6a3f6daa..f963046d 100644
--- a/track2data/exporters/schema.py
+++ b/track2data/exporters/schema.py
@@ -84,6 +84,7 @@ def depth_columns(psess: PreprocessedSession) -> dict[str, np.ndarray]:
IDENTIFIER_COLUMNS: frozenset[str] = frozenset({
"session_id", "individual_id", "metric_id", "zone_name", "from_zone",
"to_zone", "frame", "event", "note", "k",
+ "bin_index", "bin_start_s", "bin_end_s",
})
#: Exact column -> unit, for names the suffix convention gets wrong or does
@@ -126,6 +127,8 @@ def depth_columns(psess: PreprocessedSession) -> dict[str, np.ndarray]:
# Dimensionless indices and ratios.
"area_corrected_occupancy": "dimensionless (ratio)",
"cohesion_index": "dimensionless (index)",
+ # 1 / a mean distance in cm: the inverse unit (GL-6 is per pixel and is left as is).
+ "cohesion_index_3d": "1/cm",
"jacobs_d": "dimensionless (-1 to 1)",
"left_right_turn_bias": "dimensionless (-1 to 1)",
"mean_elongation_ratio": "dimensionless (ratio)",
@@ -274,7 +277,10 @@ def long_table(metrics: dict[str, pd.DataFrame]) -> pd.DataFrame:
"""
import pandas as pd
- key_columns = ["session_id", "individual_id", "zone_name", "from_zone", "to_zone"]
+ key_columns = [
+ "session_id", "individual_id", "zone_name", "from_zone", "to_zone",
+ "bin_index", "bin_start_s", "bin_end_s",
+ ]
frames: list[pd.DataFrame] = []
for metric_id, df in metrics.items():
@@ -308,12 +314,18 @@ def long_table(metrics: dict[str, pd.DataFrame]) -> pd.DataFrame:
return pd.DataFrame(columns=ordered)
out = pd.concat(frames, ignore_index=True)
+ # Time bins identify a row too; the columns appear only for a binned export, so an
+ # unbinned long table keeps its exact column set.
+ bin_columns = [c for c in ("bin_index", "bin_start_s", "bin_end_s") if c in out.columns]
+ if bin_columns:
+ at = ordered.index("metric_id")
+ ordered = [*ordered[:at], *bin_columns, *ordered[at:]]
out["unit"] = out["column"].map(unit_for_column)
for column in ordered:
if column not in out.columns:
out[column] = pd.NA
out = out[ordered]
- sort_keys = [c for c in ("session_id", "individual_id", "metric_id", "column")
+ sort_keys = [c for c in ("session_id", "individual_id", "metric_id", "column", "bin_index")
if c in out.columns]
return out.sort_values(sort_keys, na_position="last").reset_index(drop=True)
diff --git a/track2data/interop/__init__.py b/track2data/interop/__init__.py
new file mode 100644
index 00000000..ef1faa8d
--- /dev/null
+++ b/track2data/interop/__init__.py
@@ -0,0 +1,6 @@
+"""Adapters that hand a preprocessed session to other analysis packages.
+
+Not exporters: an exporter writes a file from metric results, these return in-memory objects
+built from the preprocessed positions. Each imports its third-party package lazily, so Track2Data
+itself does not depend on it.
+"""
diff --git a/track2data/interop/movement.py b/track2data/interop/movement.py
new file mode 100644
index 00000000..c40f9749
--- /dev/null
+++ b/track2data/interop/movement.py
@@ -0,0 +1,98 @@
+"""A preprocessed session as a ``movement``-style ``xarray.Dataset``.
+
+`movement `_ centres on a Dataset whose ``position``
+variable has dimensions ``(time, individuals, keypoints, space)``. This builds that from a
+:class:`~track2data.core.models.PreprocessedSession`, so its plotting, filtering and kinematics
+can be used on Track2Data's cleaned positions. ``xarray`` is imported inside the function (extra:
+``pip install track2data[xarray]``); ``movement`` itself is not needed to build the Dataset.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from track2data.core.errors import ImportError_
+
+if TYPE_CHECKING:
+ import xarray
+
+ from track2data.core.models import PreprocessedSession
+
+
+def to_movement_dataset(
+ psess: PreprocessedSession, *, include_separator_rows: bool = False
+) -> xarray.Dataset:
+ """Return *psess* as an ``xarray.Dataset`` with ``position`` ``(time, individuals, keypoints,
+ space)``.
+
+ * ``time`` is each row's true video frame divided by the frame rate (``psess.timeline()``),
+ so a session tracked in separate intervals keeps its gaps instead of being renumbered.
+ * Separator rows (the single NaN row that stands for an unfilled gap) are dropped unless
+ *include_separator_rows*. Rows inserted for unobserved video stay as NaN.
+ * ``keypoints`` is the one keypoint that stands for the animal, else ``["centroid"]``;
+ ``space`` is ``["x", "y"]``, plus ``"z"`` for a native 3-D session.
+ * The tracker's identity probability is the separate variable ``identity_confidence``. It is
+ deliberately not called ``confidence``: that is a pose estimator's confidence in *where* a
+ keypoint is, and identity probability is about *which animal* it is.
+ * ``attrs`` hold ``fps``, ``source_software``, ``reader_verification``, ``length_unit`` (the
+ unit of ``position``: ``"px"`` for a pixel session, the file's unit for a native 3-D one)
+ and ``px_per_cm`` (NaN when not calibrated).
+ """
+ try:
+ import xarray as xr
+ except ImportError as exc:
+ raise ImportError_(
+ "Building a movement Dataset needs the optional 'xarray' package.",
+ code="XARRAY_MISSING",
+ remediation="Install it with: pip install track2data[xarray]",
+ ) from exc
+
+ session = psess.session
+ n_animals = psess.n_animals
+ frames, _verified = psess.timeline()
+ keep = np.ones(psess.n_frames, dtype=bool)
+ if not include_separator_rows and psess.separator_mask is not None:
+ keep = ~psess.separator_mask
+
+ xy = psess.xy[keep]
+ space = ["x", "y"]
+ if psess.z is not None:
+ xy = np.concatenate([xy, psess.z[keep][..., None]], axis=-1)
+ space.append("z")
+
+ labels = session.identities_labels
+ if labels is None or len(labels) != n_animals:
+ labels = [str(k) for k in range(n_animals)]
+ keypoint = session.keypoints.selection.keypoint if session.keypoints is not None else "centroid"
+
+ data_vars: dict[str, tuple[tuple[str, ...], np.ndarray]] = {
+ "position": (("time", "individuals", "keypoints", "space"), xy[:, :, None, :]),
+ }
+ probabilities = psess.id_probabilities_aligned
+ if probabilities is not None and probabilities.shape[0] == psess.n_frames:
+ data_vars["identity_confidence"] = (("time", "individuals"), probabilities[keep])
+
+ from track2data.readers import find_reader
+
+ reader_cls = find_reader(session.reader)
+ native = not session.has_pixel_frame
+ return xr.Dataset(
+ data_vars,
+ coords={
+ "time": np.asarray(frames, dtype=float)[keep] / psess.fps,
+ "individuals": labels,
+ "keypoints": [keypoint],
+ "space": space,
+ },
+ attrs={
+ "fps": psess.fps,
+ "source_software": (reader_cls.display_name or reader_cls.name)
+ if reader_cls
+ else session.reader,
+ "reader_verification": reader_cls.verification if reader_cls else "unknown",
+ "length_unit": (session.coordinate_unit or "") if native else "px",
+ "px_per_cm": float("nan") if psess.px_per_cm is None else float(psess.px_per_cm),
+ },
+ )
diff --git a/track2data/metadata/join.py b/track2data/metadata/join.py
index acdc04c6..211dd941 100644
--- a/track2data/metadata/join.py
+++ b/track2data/metadata/join.py
@@ -191,6 +191,19 @@ def resolve_animal(key: str, labels: list[str], mode: str, n_animals: int) -> in
return idx if 0 <= idx < n_animals else None
+def _as_text(column: pd.Series) -> pd.Series:
+ """*column* as strings, so it compares with a session id (always text).
+
+ A key column of trial numbers is read as integers (or floats, when a row is blank); both
+ must still match the session id ``"1"``, not ``"1.0"``.
+ """
+ if pd.api.types.is_float_dtype(column):
+ return column.map(
+ lambda v: str(int(v)) if pd.notna(v) and float(v).is_integer() else str(v)
+ )
+ return column.astype(str)
+
+
def _find_rows(
session_id: str, df: pd.DataFrame, rule: MappingRule
) -> pd.DataFrame:
@@ -204,24 +217,24 @@ def _find_rows(
mask = pd.Series([True] * len(df), index=df.index)
for col, val in groups.items():
if col in df.columns:
- mask &= df[col].astype(str) == str(val)
+ mask &= _as_text(df[col]) == str(val)
return df[mask]
return df.iloc[0:0] # empty
# Strategy 2: composite / exact key join.
keys = rule.join_keys if rule.join_keys else ["session_id"]
if "session_id" in keys and "session_id" in df.columns:
- return df[df["session_id"] == session_id]
+ return df[_as_text(df["session_id"]) == session_id]
# Composite: match session_id against concatenated key columns.
if len(keys) > 1:
sep = "_"
- composite = df[keys].astype(str).agg(sep.join, axis=1)
+ composite = pd.concat([_as_text(df[k]) for k in keys], axis=1).agg(sep.join, axis=1)
return df[composite == session_id]
# Single non-session_id key: fallback to exact value match.
key = keys[0]
if key in df.columns:
- return df[df[key] == session_id]
+ return df[_as_text(df[key]) == session_id]
return df.iloc[0:0]
diff --git a/track2data/metadata/loader.py b/track2data/metadata/loader.py
index c30d0b15..9a55bede 100644
--- a/track2data/metadata/loader.py
+++ b/track2data/metadata/loader.py
@@ -1,7 +1,9 @@
"""
CSV / XLSX metadata loader with type coercion.
-``load(path)`` returns a cleaned ``pandas.DataFrame``:
+``load(path, sheet=None)`` returns a cleaned ``pandas.DataFrame`` (``sheet`` picks a sheet of an
+Excel workbook; the first one by default). ``list_sheets`` names a workbook's sheets and
+``preview`` gives the header and first rows exactly as stored, for the metadata screen:
- Column names are stripped and lowercased.
- String columns are stripped of leading/trailing whitespace.
- Date-like columns are parsed to ``datetime64``.
@@ -10,19 +12,88 @@
from __future__ import annotations
import contextlib
+import csv
from pathlib import Path
import pandas as pd
+_EXCEL_SUFFIXES = {".xlsx", ".xlsm"}
-def load(path: Path) -> pd.DataFrame:
+
+def _check_suffix(path: Path) -> str:
+ """The lower-case suffix of a supported metadata file, else ``ValueError``."""
+ suffix = path.suffix.lower()
+ if suffix == ".xls":
+ raise ValueError(
+ f"{path.name} is an old-format Excel file (.xls), which is not supported. "
+ "Save it as .xlsx or as a CSV and load that."
+ )
+ if suffix not in _EXCEL_SUFFIXES | {".csv"}:
+ raise ValueError(f"Unsupported metadata file format: {suffix!r}")
+ return suffix
+
+
+def list_sheets(path: Path) -> list[str]:
+ """The sheet names of an Excel workbook, in order; ``[]`` for a CSV.
+
+ Raises ``ValueError`` for a format that is not supported (including ``.xls``).
+ """
+ path = Path(path)
+ if _check_suffix(path) == ".csv":
+ return []
+ with pd.ExcelFile(path, engine="openpyxl") as workbook:
+ return [str(name) for name in workbook.sheet_names]
+
+
+def _sheet_to_read(path: Path, sheet: str | None) -> str | int:
+ """*sheet* if the workbook has it, the first sheet when None, else ``ValueError`` that
+ lists the sheets there are."""
+ if sheet is None:
+ return 0
+ names = list_sheets(path)
+ if sheet not in names:
+ raise ValueError(
+ f"{path.name} has no sheet {sheet!r}; its sheets are: {', '.join(names) or '(none)'}."
+ )
+ return sheet
+
+
+def preview(
+ path: Path, sheet: str | None = None, rows: int = 5
+) -> tuple[list[str], list[list[str]]]:
+ """The header and the first *rows* data rows as text, as stored (column names keep their case).
+
+ Empty lists for an empty file or sheet. Raises like :func:`load`.
+ """
+ path = Path(path)
+ if not path.exists():
+ raise FileNotFoundError(f"Metadata file not found: {path}")
+ if _check_suffix(path) == ".csv":
+ lines: list[list[str]] = []
+ with open(path, newline="", encoding="utf-8-sig") as handle:
+ for i, row in enumerate(csv.reader(handle)):
+ lines.append(row)
+ if i >= rows: # header + rows
+ break
+ return (lines[0], lines[1:]) if lines else ([], [])
+ frame = pd.read_excel(
+ path, sheet_name=_sheet_to_read(path, sheet), engine="openpyxl", dtype=str, nrows=rows
+ )
+ headers = [str(c) for c in frame.columns]
+ return headers, [["" if pd.isna(v) else str(v) for v in row] for row in frame.to_numpy()]
+
+
+def load(path: Path, sheet: str | None = None) -> pd.DataFrame:
"""
Load a metadata file (CSV or XLSX) and return a cleaned DataFrame.
Parameters
----------
path:
- Path to a ``.csv``, ``.xlsx``, or ``.xls`` file.
+ Path to a ``.csv`` or ``.xlsx`` file (the old ``.xls`` format is refused).
+ sheet:
+ Name of the sheet to read from a workbook. ``None`` (the default) reads the first sheet,
+ as before. Ignored for a CSV.
Returns
-------
@@ -34,19 +105,17 @@ def load(path: Path) -> pd.DataFrame:
FileNotFoundError
If *path* does not exist.
ValueError
- If the file extension is not recognised.
+ If the file extension is not recognised, or *sheet* is not in the workbook (the message
+ lists the sheets there are).
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Metadata file not found: {path}")
- suffix = path.suffix.lower()
- if suffix in {".xlsx", ".xls"}:
- df = pd.read_excel(path, sheet_name=0, engine="openpyxl")
- elif suffix == ".csv":
+ if _check_suffix(path) == ".csv":
df = pd.read_csv(path, encoding="utf-8-sig")
else:
- raise ValueError(f"Unsupported metadata file format: {suffix!r}")
+ df = pd.read_excel(path, sheet_name=_sheet_to_read(path, sheet), engine="openpyxl")
# Normalise column names: strip whitespace + lowercase.
df.columns = [str(c).strip().lower() for c in df.columns]
diff --git a/track2data/metrics/base.py b/track2data/metrics/base.py
index a95ac62a..d374db02 100644
--- a/track2data/metrics/base.py
+++ b/track2data/metrics/base.py
@@ -156,7 +156,7 @@ class Metric(ABC):
window_safe: ClassVar[bool] = True
output_columns: ClassVar[list[str]]
documentation: ClassVar[MetricDocumentation]
- # Most metrics (33 of 54 today) take no configuration at all --
+ # Most metrics (35 of 57 today) take no configuration at all --
# an empty default, not a required field, so every existing
# metric class stays valid without declaring it. The figure is
# pinned by tests/test_metric_references_consistency.py.
diff --git a/track2data/metrics/group.py b/track2data/metrics/group.py
index d98c6145..2f9876ac 100644
--- a/track2data/metrics/group.py
+++ b/track2data/metrics/group.py
@@ -10,6 +10,7 @@
from __future__ import annotations
import logging
+from collections.abc import Callable
from typing import ClassVar
import numpy as np
@@ -303,13 +304,7 @@ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.D
# Same rule as GL-1's mean_nnd_bl (nan-mean of the raw per-animal lengths, valid when
# the mean is positive), averaged in cm so ordinary sessions keep their exact value.
- bl_px = session.body_length_in_px()
- px_per_cm = session.px_per_cm
- mean_bl = np.nan
- if bl_px is not None and px_per_cm is not None:
- bl_cm = np.asarray(bl_px, dtype=np.float64) / px_per_cm
- if not np.isnan(bl_cm).all(): # nanmean warns only when every entry is NaN
- mean_bl = float(np.nanmean(bl_cm))
+ mean_bl = _mean_body_length_cm(session)
mean_nnd_bl = mean_nnd / mean_bl if mean_bl > 0 and not np.isnan(mean_nnd) else np.nan
return pd.DataFrame(
@@ -327,6 +322,222 @@ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.D
)
+def _mean_body_length_cm(session: PreprocessedSession) -> float:
+ """Mean body length over animals in cm (NaN when unknown): GL-1's ``mean_nnd_bl`` rule
+ (nan-mean of the raw per-animal lengths, valid when positive), taken in cm."""
+ bl_px = session.body_length_in_px()
+ px_per_cm = session.px_per_cm
+ if bl_px is None or px_per_cm is None:
+ return np.nan
+ bl_cm = np.asarray(bl_px, dtype=np.float64) / px_per_cm
+ if np.isnan(bl_cm).all(): # nanmean warns only when every entry is NaN
+ return np.nan
+ return float(np.nanmean(bl_cm))
+
+
+def _frame_distances_3d(
+ session: PreprocessedSession, per_frame: Callable[[np.ndarray], float]
+) -> tuple[list[float], int, bool]:
+ """``per_frame(points)`` for every counted frame with a full 3-D position for every animal.
+
+ Returns ``(values, n_skipped, available)``. *available* is False without 3-D positions or
+ with fewer than two animals; ``n_skipped`` is then every counted frame, as GL-16 reports it.
+ A separator row is not a frame, and a frame with any NaN is skipped and counted.
+ """
+ pos = positions_cm(session)
+ counted = session.counted_rows()
+ n_counted = int(counted.sum())
+ if pos is None or session.n_animals < 2:
+ return [], n_counted, False
+ values: list[float] = []
+ skipped = 0
+ for t in range(pos.shape[0]):
+ if not counted[t]:
+ continue
+ points = pos[t]
+ if np.isnan(points).any():
+ skipped += 1
+ continue
+ values.append(per_frame(points))
+ return values, skipped, True
+
+
+def _nnd_of(points: np.ndarray) -> float:
+ dists, _ = cKDTree(points).query(points, k=2)
+ return float(dists[:, 1].mean())
+
+
+class InterIndividualDistance3D(Metric):
+ """GL-18 — Mean inter-individual distance (IID) in 3-D (cm) on a fused session."""
+
+ id = "GL-18"
+ name = "inter_individual_distance_3d"
+ label = "3-D Inter-Individual Distance"
+ level = "group"
+ priority = "optional"
+ # Like GL-2: an unordered point set per frame, so slot identity is irrelevant.
+ requires_identity = False
+ requires_depth_scale = True
+ output_columns: ClassVar[list[str]] = [
+ "session_id",
+ "metric_id",
+ "mean_iid_3d_cm",
+ "median_iid_3d_cm",
+ "mean_iid_3d_bl",
+ "n_skipped_frames_3d",
+ "frac_frames_used_3d",
+ ]
+ documentation = MetricDocumentation(
+ definition=(
+ "GL-2 in three dimensions. For each frame the mean of all pairwise Euclidean "
+ "distances between animals, in cm, in (X, Y, Z) space (X, Y from the top view, Z "
+ "from the side-view depth times the tank height); the metric reports the mean and "
+ "median of the frame-level values."
+ ),
+ formula_plain=(
+ "P[t,k] = (x/px_per_cm, y/px_per_cm, depth*tank_height_cm); "
+ "per frame iid[t] = mean(pdist(P[t])); metric = mean/median over frames"
+ ),
+ inputs=[
+ "PreprocessedSession.xy",
+ "PreprocessedSession.depth (fused)",
+ "PreprocessedSession.px_per_cm",
+ "PreprocessedSession.depth_height_cm",
+ ],
+ assumptions=[
+ "The depth comes from a side camera at right angles to the top camera (a native "
+ "3-D file holds one animal per session, so this metric is not offered for it)",
+ "A frame needs finite X, Y and Z for every animal; other frames are skipped and "
+ "counted, so a frame GL-2 keeps can be skipped here",
+ "Body length for the BL column is the mean over animals, as for GL-16",
+ ],
+ warnings=[
+ "Skipped-frame count and frac_frames_used_3d are reported; high counts may bias "
+ "the metric",
+ "GL-2 and GL-18 are computed on different frames whenever depth is missing, so "
+ "mean_iid_px and mean_iid_3d_cm are not a like-for-like comparison",
+ "No refraction or parallax correction; a wrong tank height scales every vertical "
+ "distance",
+ "Without depth or a cm scale all statistics are NaN",
+ ],
+ primary_reference=KRAUSE_RUXTON_2002,
+ supporting_references=[MILLER_GERLAI_2007, DELCOURT_PONCIN_2012],
+ )
+
+ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.DataFrame:
+ """3-D inter-individual distance statistics for *session* (NaN without depth/scale)."""
+ values, skipped, available = _frame_distances_3d(session, lambda p: float(pdist(p).mean()))
+ n_counted = int(session.counted_rows().sum())
+ mean_iid = float(np.mean(values)) if values else np.nan
+ median_iid = float(np.median(values)) if values else np.nan
+ mean_bl = _mean_body_length_cm(session)
+ return pd.DataFrame(
+ [
+ {
+ "session_id": session.session_id,
+ "metric_id": self.id,
+ "mean_iid_3d_cm": mean_iid,
+ "median_iid_3d_cm": median_iid,
+ "mean_iid_3d_bl": (
+ mean_iid / mean_bl if mean_bl > 0 and not np.isnan(mean_iid) else np.nan
+ ),
+ "n_skipped_frames_3d": skipped,
+ "frac_frames_used_3d": (
+ len(values) / n_counted if available and n_counted else np.nan
+ ),
+ }
+ ],
+ columns=self.output_columns,
+ )
+
+
+class GroupCohesion3D(Metric):
+ """GL-19 — Group cohesion in 3-D = 1 / mean 3-D distance (cm)."""
+
+ id = "GL-19"
+ name = "group_cohesion_3d"
+ label = "3-D Group Cohesion"
+ level = "group"
+ priority = "optional"
+ requires_identity = False
+ requires_depth_scale = True
+ output_columns: ClassVar[list[str]] = [
+ "session_id",
+ "metric_id",
+ "cohesion_index_3d",
+ ]
+ documentation = MetricDocumentation(
+ definition=(
+ "GL-6 in three dimensions: the reciprocal of the mean 3-D distance in cm, from "
+ "nearest-neighbour distance (as GL-16) or from the mean pairwise distance (as "
+ "GL-18). Higher values mean a more cohesive (closer) group."
+ ),
+ formula_plain=(
+ "cohesion_index_3d = 1 / mean_NND_3d (cohesion_source='nnd', default), "
+ "or 1 / mean_IID_3d (cohesion_source='iid'), in cm"
+ ),
+ inputs=[
+ "PreprocessedSession.xy",
+ "PreprocessedSession.depth (fused)",
+ "PreprocessedSession.px_per_cm",
+ "PreprocessedSession.depth_height_cm",
+ ],
+ assumptions=[
+ "Distances are computed exactly as GL-16 ('nnd') and GL-18 ('iid'); a frame needs "
+ "finite X, Y and Z for every animal",
+ ],
+ warnings=[
+ "The index is per cm, so it is not comparable with GL-6, which is per pixel",
+ "Undefined (NaN) when the mean distance is 0, when fewer than 2 animals, or "
+ "without depth or a cm scale",
+ ],
+ primary_reference=KRAUSE_RUXTON_2002,
+ supporting_references=[DELCOURT_PONCIN_2012],
+ )
+ parameters: ClassVar[list[MetricParameter]] = [
+ MetricParameter(
+ name="cohesion_source",
+ label="Cohesion source",
+ kind="choice",
+ default="nnd",
+ choices=list(_COHESION_SOURCES),
+ help=(
+ "Which 3-D distance cohesion is derived from: nearest-neighbour distance (nnd) "
+ "or mean inter-individual distance (iid)."
+ ),
+ ),
+ ]
+
+ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.DataFrame:
+ """3-D group cohesion for *session*; ``cfg['cohesion_source']`` is 'nnd' or 'iid'."""
+ source = "nnd"
+ if cfg is not None and "cohesion_source" in cfg:
+ source = cfg["cohesion_source"]
+ # Refuse rather than fall through: a typo would export NND numbers while the project
+ # records the user choosing IID (the same rule as GL-6).
+ if source not in _COHESION_SOURCES:
+ raise ValueError(
+ f"cohesion_source must be one of "
+ f"{', '.join(sorted(_COHESION_SOURCES))}; got {source!r}"
+ )
+ per_frame = _nnd_of if source == "nnd" else (lambda p: float(pdist(p).mean()))
+ values, _skipped, _available = _frame_distances_3d(session, per_frame)
+ cohesion = np.nan
+ if values:
+ mean_dist = float(np.mean(values))
+ cohesion = 1.0 / mean_dist if mean_dist != 0 else np.nan
+ return pd.DataFrame(
+ [
+ {
+ "session_id": session.session_id,
+ "metric_id": self.id,
+ "cohesion_index_3d": cohesion,
+ }
+ ],
+ columns=self.output_columns,
+ )
+
+
# ── GL-3: Polarisation ────────────────────────────────────────────────────────
@@ -1755,3 +1966,5 @@ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.D
_register(TopologicalNeighbourCounts)
_register(GroupElongation)
_register(NearestNeighbourDistance3D)
+_register(InterIndividualDistance3D)
+_register(GroupCohesion3D)
diff --git a/track2data/metrics/individual.py b/track2data/metrics/individual.py
index dad0f2b3..968f62d1 100644
--- a/track2data/metrics/individual.py
+++ b/track2data/metrics/individual.py
@@ -260,6 +260,9 @@ class CentreDistance(Metric):
name = "centre_distance"
label = "Distance from Arena Centre"
level = "individual"
+ # Arena-plan geometry: meaningless in a side view. "unknown" stays in so a project that never
+ # declared a view keeps both metrics.
+ valid_camera_views = frozenset({"top", "unknown"})
priority = "primary"
requires_identity = True
output_columns: ClassVar[list[str]] = [
@@ -1489,6 +1492,8 @@ class WallDistanceThigmotaxis(Metric):
id = "IL-14"
name = "wall_distance_thigmotaxis"
+ # As IL-3: distance to the arena boundary only makes sense in plan view.
+ valid_camera_views = frozenset({"top", "unknown"})
label = "Wall-Distance Thigmotaxis"
level = "individual"
priority = "primary"
@@ -2056,6 +2061,101 @@ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.D
return pd.DataFrame(records, columns=self.output_columns)
+class VerticalSpeed3D(Metric):
+ """IL-18 — Vertical speed (cm/s) of a fused top + side session, or of a native 3-D file's z."""
+
+ id = "IL-18"
+ name = "vertical_speed_3d"
+ label = "Vertical Speed (mean / median / max)"
+ level = "individual"
+ priority = "optional"
+ requires_identity = True
+ requires_depth_scale = True
+ uses_kinematics_cfg = True
+ output_columns: ClassVar[list[str]] = [
+ "session_id",
+ "metric_id",
+ "individual_id",
+ # Emitted unconditionally: NaN when the session has no depth or no cm scale.
+ "mean_vertical_speed_cm_s",
+ "median_vertical_speed_cm_s",
+ "max_vertical_speed_cm_s",
+ "mean_vertical_speed_bl_s",
+ ]
+ documentation = MetricDocumentation(
+ definition=(
+ "Mean, median and maximum speed of each individual along the vertical axis, in "
+ "cm/s: how fast it climbs or dives, regardless of how fast it swims sideways. It is "
+ "the vertical part of IL-17 reported on its own, for a fused 3-D session (the side "
+ "view's depth times the tank height) or a native 3-D file (its absolute z)."
+ ),
+ formula_plain=(
+ "|vz| per frame, the derivative of the vertical coordinate in cm from the same "
+ "estimator and window as the pipeline's kinematics; mean/median/max over frames "
+ "where it is finite"
+ ),
+ inputs=[
+ "PreprocessedSession.depth (fused) or PreprocessedSession.z (native 3-D file)",
+ "PreprocessedSession.depth_height_cm (fused only)",
+ "PreprocessedSession.px_per_cm",
+ "KinematicsCfg (the pipeline's estimator and window)",
+ ],
+ assumptions=[
+ "The speed is unsigned (|vz|), so a climb and a dive count the same; a signed or "
+ "split climb/dive measure is not offered",
+ "Where the estimator returns NaN (the last frame of the forward-difference "
+ "estimator, a depth segment too short for the window) the frame is left out, never "
+ "counted as zero",
+ "The depth comes from a side camera at right angles to the top camera; a native "
+ "3-D file's vertical series is its absolute z, with samples outside the water "
+ "column (when one is set) left out",
+ ],
+ warnings=[
+ "Max speed is sensitive to depth noise and remaining jump artefacts.",
+ "No refraction or parallax correction. A wrong tank height scales every vertical "
+ "speed by the same factor.",
+ "Needs a cm scale for the top view; without it (or without depth) every column "
+ "is NaN.",
+ ],
+ citation="Standard kinematics",
+ supporting_references=[BJORNERAAS_2010],
+ )
+
+ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.DataFrame:
+ """Vertical speed statistics per individual; NaN columns without depth or a cm scale.
+
+ ``cfg["kinematics"]`` is the estimator the pipeline used (the Engine supplies it); the
+ whole-session series the Engine precomputed (``session.z_speed``) wins, so a window sees
+ the whole-session estimate at its edge frames.
+ """
+ vz = session.z_speed
+ if vz is None:
+ vz = vertical_speed(session, (cfg or {}).get("kinematics") or {})
+
+ records: list[dict] = []
+ for k in range(session.n_animals):
+ mean_s = median_s = max_s = float("nan")
+ if vz is not None:
+ valid = vz[:, k][np.isfinite(vz[:, k])]
+ if len(valid) > 0:
+ mean_s = float(np.mean(valid))
+ median_s = float(np.median(valid))
+ max_s = float(np.max(valid))
+ bl_cm = body_length_cm(session, k)
+ records.append(
+ {
+ "session_id": session.session_id,
+ "metric_id": self.id,
+ "individual_id": k,
+ "mean_vertical_speed_cm_s": mean_s,
+ "median_vertical_speed_cm_s": median_s,
+ "max_vertical_speed_cm_s": max_s,
+ "mean_vertical_speed_bl_s": mean_s / bl_cm if bl_cm > 0 else float("nan"),
+ }
+ )
+ return pd.DataFrame(records, columns=self.output_columns)
+
+
# ── Registration ──────────────────────────────────────────────────────────────
from track2data.metrics import register as _register # noqa: E402
@@ -2075,3 +2175,4 @@ def compute(self, session: PreprocessedSession, cfg: dict | None = None) -> pd.D
_register(VerticalPosition)
_register(PathLength3D)
_register(Speed3D)
+_register(VerticalSpeed3D)
diff --git a/track2data/metrics/zone.py b/track2data/metrics/zone.py
index a6978c7b..398847c4 100644
--- a/track2data/metrics/zone.py
+++ b/track2data/metrics/zone.py
@@ -108,6 +108,23 @@ def _run_lengths(mask: np.ndarray) -> list[int]:
return lengths
+def _split_off_separators(
+ segments: list[tuple[int, int]], separator: np.ndarray
+) -> list[tuple[int, int]]:
+ """Cut each (start, end) row segment so that no separator row lies inside it."""
+ out: list[tuple[int, int]] = []
+ for seg_start, seg_end in segments:
+ start = seg_start
+ for row in range(seg_start, seg_end):
+ if separator[row]:
+ if row > start:
+ out.append((start, row))
+ start = row + 1
+ if seg_end > start:
+ out.append((start, seg_end))
+ return out
+
+
def _true_run_spans(mask: np.ndarray, min_length: int) -> list[tuple[int, int]]:
"""Return (start, end) index pairs -- end exclusive -- for each run
of consecutive True values in mask at least min_length long. Used
@@ -981,6 +998,12 @@ def compute(self, session: object, cfg: dict | None = None) -> pd.DataFrame:
n_rows = len(true_frames)
starts = segment_starts(true_frames)
segments = list(zip([0, *starts], [*starts, n_rows], strict=True))
+ separator = getattr(session, "separator_mask", None)
+ if separator is not None:
+ # A separator row only keeps two intervals apart; it is not a frame, so an
+ # in-zone run must end on the last real frame before it, never on the separator
+ # (which would fabricate an 'exit' where observation merely stopped).
+ segments = _split_off_separators(segments, np.asarray(separator, dtype=bool))
def _event(
zone_name: str, k: int, kind: str, row: int, after_gap: bool
diff --git a/track2data/readers/__init__.py b/track2data/readers/__init__.py
index 0d2989a5..15d938c1 100644
--- a/track2data/readers/__init__.py
+++ b/track2data/readers/__init__.py
@@ -20,6 +20,7 @@
from track2data.readers.ctrax_mat import CtraxMatReader
from track2data.readers.deeplabcut import DeepLabCutReader
from track2data.readers.deeplabcut_3d import DeepLabCut3DReader
+from track2data.readers.deeplabcut_h5 import DeepLabCutH5Reader
from track2data.readers.idtrackerai.reader import IDTrackerAiReader
from track2data.readers.idtrackerai_v4 import looks_like_v4
from track2data.readers.idtrackerai_v5 import IDTrackerAiV5Reader
@@ -59,6 +60,7 @@ def _load_entry_points() -> None:
register(IDTrackerAiReader)
register(IDTrackerAiV5Reader)
register(DeepLabCutReader)
+register(DeepLabCutH5Reader)
register(AniposeReader)
register(DeepLabCut3DReader)
register(SleapAnalysisReader)
diff --git a/track2data/readers/advisories.py b/track2data/readers/advisories.py
index 30794e3c..1faebc31 100644
--- a/track2data/readers/advisories.py
+++ b/track2data/readers/advisories.py
@@ -5,6 +5,8 @@
* A reader written from a format's documentation alone (``verification == "synthetic_only"``)
may disagree with the real files in ways only real output reveals (DECISIONS D-012).
+* A tracker that reports fragments (a Ctrax file with one slot per track id) leaves most slots
+ empty, and group metrics skip every frame with a missing animal.
* The default "bodylength" calibration needs a body length, which most trackers do not report.
Without one it is skipped and the session is exported in pixels only.
"""
@@ -17,11 +19,13 @@
from track2data.readers import find_reader
_LISTED = 4
+#: Warn when at least this share of a multi-animal session's (frame, animal) slots is empty.
+FRAGMENTED_NAN_FRACTION = 0.5
def reader_advisories(summaries: Sequence[SessionSummary]) -> list[str]:
"""One plain-language warning per caveat, naming the sessions it applies to."""
- return [*_unverified(summaries), *_missing_body_length(summaries)]
+ return [*_unverified(summaries), *_missing_body_length(summaries), *_fragmented(summaries)]
def _unverified(summaries: Sequence[SessionSummary]) -> list[str]:
@@ -54,6 +58,29 @@ def _missing_body_length(summaries: Sequence[SessionSummary]) -> list[str]:
]
+def _fragmented(summaries: Sequence[SessionSummary]) -> list[str]:
+ fragmented = [
+ s
+ for s in summaries
+ if s.n_animals > 1
+ and s.nan_slot_fraction is not None
+ and s.nan_slot_fraction >= FRAGMENTED_NAN_FRACTION
+ ]
+ if not fragmented:
+ return []
+ detail = ", ".join(
+ f"{s.session_id} ({s.nan_slot_fraction:.0%} of animal slots empty)"
+ for s in fragmented[:_LISTED]
+ )
+ more = f", +{len(fragmented) - _LISTED} more" if len(fragmented) > _LISTED else ""
+ return [
+ "Most animal slots are empty in some sessions, which usually means the tracker reported "
+ "track fragments rather than continuous animals. Group metrics skip every frame in which "
+ "any animal is missing, so they will see few usable frames. Sessions: "
+ f"{detail}{more}. For Ctrax files, the 'top_n' option keeps only the longest tracks."
+ ]
+
+
def _ids_by(
summaries: Sequence[SessionSummary], *, key: Callable[[SessionSummary], str]
) -> dict[str, list[str]]:
diff --git a/track2data/readers/base.py b/track2data/readers/base.py
index fd0b3ffc..ca8e8de8 100644
--- a/track2data/readers/base.py
+++ b/track2data/readers/base.py
@@ -100,6 +100,16 @@ def accept(folder: Path) -> SessionCandidate | None:
)
]
+ @classmethod
+ def discover_lenient(cls, index: ScanIndex, peek: Peeker) -> list[Detection]:
+ """Sessions this reader would accept under loosened rules, for the "choose software" retry.
+
+ Called only when the user names this reader after a scan recognised nothing it would
+ take. Report only files :meth:`discover` refused, and only ones ``read`` can still parse;
+ the default reports none. Must not raise.
+ """
+ return []
+
@abstractmethod
def read(self, folder: Path, *, allow_pickle: bool = False) -> Session:
"""
diff --git a/track2data/readers/confirm.py b/track2data/readers/confirm.py
index d14428eb..2e4dcf47 100644
--- a/track2data/readers/confirm.py
+++ b/track2data/readers/confirm.py
@@ -12,6 +12,7 @@
from __future__ import annotations
+import dataclasses
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
@@ -21,9 +22,11 @@
from track2data.core.errors import ImportError_
from track2data.core.models import SessionRef
+from track2data.core.session_identity import same_session_part
from track2data.readers.detection import Confidence, Detection
from track2data.readers.params import ReaderParameter, ValueSource, resolve_options
-from track2data.readers.scan import FormatGroup, ScanResult
+from track2data.readers.scan import FormatGroup, ScanResult, lenient_scan
+from track2data.readers.video_meta import VideoProbe, probe_video
ProblemCode = Literal[
"NOTHING_SELECTED", "OPTION_MISSING", "OPTION_INVALID", "ID_DUPLICATE", "ID_UNSAFE"
@@ -64,11 +67,8 @@ class SessionRow:
warnings: tuple[str, ...] = ()
-def _resolved(path: Path) -> Path:
- try:
- return Path(path).resolve()
- except OSError:
- return Path(path)
+#: The shared options a video file can answer.
+_VIDEO_OPTIONS = frozenset({"fps", "width_px", "height_px"})
class ConfirmDraft:
@@ -82,9 +82,9 @@ class ConfirmDraft:
def __init__(self, result: ScanResult, *, existing: Sequence[SessionRef] = ()) -> None:
self._result = result
- # (resolved folder, reader or None) of what the project already holds. A legacy entry
- # (no saved reader) is the same session whichever reader reads it.
- self._existing = [(_resolved(ref.folder), ref.reader) for ref in existing]
+ # What the project already holds. A legacy entry (no saved reader) is the same session
+ # whichever reader reads it; one with a panel is another part of the video.
+ self._existing = list(existing)
self._existing_ids = {ref.session_id for ref in existing}
self._group_index = 0
self._detection: Detection | None = None
@@ -164,6 +164,21 @@ def select_group(self, index: int) -> None:
self._group_index = index
self._enter(self._result.groups[index].best, "detected")
+ def offer_reader(self, name: str) -> bool:
+ """Try reader *name* with its loosened rules on the files the scan walked.
+
+ On success its sessions become a new, first group (LOW confidence) and are selected, so
+ :meth:`select_reader` can name it; returns False when it finds nothing to read. Only
+ readers that implement ``discover_lenient`` can be offered.
+ """
+ group = lenient_scan(self._result, name)
+ if group is None:
+ return False
+ self._result = dataclasses.replace(self._result, groups=(group, *self._result.groups))
+ self._group_index = 0
+ self._enter(group.best, "user")
+ return True
+
def select_reader(self, name: str) -> None:
"""Amend the suggestion: read the selected group with another reader that recognised it."""
if self.is_empty:
@@ -200,6 +215,30 @@ def set_option(self, name: str, value: Any) -> None:
self._values[name] = value
self._sources[name] = "user"
+ @property
+ def accepts_video_probe(self) -> bool:
+ """Whether the selected reader asks for something a video can answer (frame rate, size)."""
+ names = {p.name for p in self.parameters if p.scope == "group"}
+ return bool(names & _VIDEO_OPTIONS)
+
+ def prefill_from_video(self, path: Path) -> VideoProbe | None:
+ """Fill the frame rate and frame size from the video at *path*.
+
+ Only the options this reader has, and never one the user already typed (its source is
+ "user"). The filled values carry the source "video". Returns what the video said, or
+ None when it could not be read (nothing is changed then).
+ """
+ probe = probe_video(Path(path))
+ if probe is None:
+ return None
+ answers = {"fps": probe.fps, "width_px": probe.width_px, "height_px": probe.height_px}
+ group = {p.name for p in self.parameters if p.scope == "group"}
+ for name, value in answers.items():
+ if name in group and self._sources.get(name) != "user":
+ self._values[name] = value
+ self._sources[name] = "video"
+ return probe
+
def set_session_option(self, session_id: str, name: str, value: Any) -> None:
"""Set an option that differs per session (which arena); ``None`` clears it."""
self._row_key(session_id)
@@ -228,7 +267,7 @@ def rows(self) -> tuple[SessionRow, ...]:
session_id=self._ids[candidate.session_id],
source=candidate.source,
included=self._included[candidate.session_id],
- already_added=self._already_added(candidate.source),
+ already_added=self._already_added(candidate.session_id, candidate.source),
warnings=candidate.warnings,
)
for candidate in self._detection.sessions
@@ -305,15 +344,22 @@ def _enter(self, detection: Detection, chosen_by: Literal["detected", "user"]) -
self._per_session = {}
self._ids = {c.session_id: c.session_id for c in detection.sessions}
self._included = {
- c.session_id: not self._already_added(c.source) for c in detection.sessions
+ c.session_id: not self._already_added(c.session_id, c.source)
+ for c in detection.sessions
}
- def _already_added(self, source: Path) -> bool:
- resolved = _resolved(source)
- return any(
- path == resolved and (reader is None or reader == self.reader)
- for path, reader in self._existing
+ def _already_added(self, original_id: str, source: Path) -> bool:
+ """Whether the project holds this candidate as a whole-frame session of this reader."""
+ if not self._existing:
+ return False
+ candidate = SessionRef(
+ session_id=original_id,
+ folder=source,
+ sha256="",
+ reader=self.reader,
+ reader_options={**self._values, **self._per_session.get(original_id, {})},
)
+ return any(same_session_part(candidate, ref) for ref in self._existing)
def _row_key(self, session_id: str) -> None:
if session_id not in self._ids:
diff --git a/track2data/readers/deeplabcut.py b/track2data/readers/deeplabcut.py
index 322fd840..3355bd7f 100644
--- a/track2data/readers/deeplabcut.py
+++ b/track2data/readers/deeplabcut.py
@@ -81,6 +81,10 @@ def has_extra_coords(self) -> bool:
return any(c[2] not in _NEEDED for c in self.columns)
+#: Extensions a prediction table is sometimes saved under (the choose-software retry).
+_LENIENT_SUFFIXES = frozenset({".txt", ".dat", ""})
+
+
def _parse_header(lines: list[str]) -> _Header | None:
"""The header the first lines describe, or None if this is not a prediction table."""
if len(lines) < 3:
@@ -260,6 +264,43 @@ def discover(cls, index: ScanIndex, peek: Peeker) -> list[Detection]:
)
]
+ @classmethod
+ def discover_lenient(cls, index: ScanIndex, peek: Peeker) -> list[Detection]:
+ """Prediction tables saved under another name (``.txt``, ``.dat`` or none).
+
+ The header must still be a full DeepLabCut one, so only the extension is loosened.
+ """
+ sessions = []
+ for entry in index.walk():
+ if entry.is_dir or entry.suffix not in _LENIENT_SUFFIXES or entry.cloud_only:
+ continue
+ lines = peek.text_lines(entry.path, 5)
+ header = _parse_header(lines) if lines else None
+ if header is not None:
+ sessions.append(
+ SessionCandidate(
+ session_id=default_session_id(entry.path, is_file=True),
+ source=entry.path,
+ files=(entry.path,),
+ warnings=_warnings_for(header),
+ )
+ )
+ if not sessions:
+ return []
+ return [
+ Detection(
+ reader=cls.name,
+ display_name=cls.display_name,
+ confidence=Confidence.LOW,
+ evidence=(
+ f"{len(sessions)} file(s) with a DeepLabCut header but not a .csv extension",
+ ),
+ sessions=tuple(sessions),
+ parameters=cls.parameters,
+ verification=cls.verification,
+ )
+ ]
+
# ── reading ────────────────────────────────────────────────────────────
def read(self, folder: Path, *, allow_pickle: bool = False, options: Any = None) -> Session:
@@ -386,7 +427,14 @@ def _read_table(path: Path, header: _Header) -> _Table:
def _build_session(
- given: Path, path: Path, header: _Header, table: _Table, opts: dict[str, Any]
+ given: Path,
+ path: Path,
+ header: _Header,
+ table: _Table,
+ opts: dict[str, Any],
+ *,
+ reader_name: str = "deeplabcut",
+ trajectory_format: str = "deeplabcut_csv",
) -> Session:
wanted = _chosen_individuals(header, opts.get("individuals"))
animals = wanted if header.multi else [""]
@@ -439,16 +487,19 @@ def _build_session(
session = assemble_session(
session_id=default_session_id(path, is_file=True),
folder=given,
- reader=DeepLabCutReader.name,
+ reader=reader_name,
fps=opts["fps"],
width_px=opts["width_px"],
height_px=opts["height_px"],
raw_xy=reduction.raw_xy,
has_stable_identities=not placeholders,
+ # ind1, ind2... are positional slots, not identities: say so, so identity-requiring
+ # metrics are skipped and no gap is bridged across tracking intervals (as SLEAP does).
+ track_wo_identities=True if placeholders else None,
identities_labels=list(animals) if header.multi else None,
keypoints=keypoints,
trajectory_source=path,
- trajectory_format="deeplabcut_csv",
+ trajectory_format=trajectory_format,
length_unit=opts.get("px_per_unit"),
)
if opts.get("keep_skeleton") and keypoints is None:
diff --git a/track2data/readers/deeplabcut_h5.py b/track2data/readers/deeplabcut_h5.py
new file mode 100644
index 00000000..47143086
--- /dev/null
+++ b/track2data/readers/deeplabcut_h5.py
@@ -0,0 +1,328 @@
+"""DeepLabCut prediction tables as HDF5 (``*.h5``), the file DeepLabCut writes beside the CSV.
+
+DeepLabCut saves ``df.to_hdf(path, key="df_with_missing", format="table")``. The numbers are a
+``table`` compound dataset (``index`` plus ``values_block_N`` arrays). The column labels
+``(scorer, [individual,] bodypart, coord)`` are not stored as text: pandas pickles them into HDF5
+attributes. They are decoded here with an unpickler that refuses **every** global (the pickles
+pandas writes for labels are protocol 0 and hold only lists, tuples, strings and integers), and
+each attribute is size-capped. PyTables is neither imported nor needed (DECISIONS D-050). The scan
+itself never unpickles: it only sees the plain-text ``pandas_type`` marker (``Peeker.hdf5_root``).
+
+After decoding, the table goes through the same ``_build_session`` as the CSV reader, so the two
+formats of one prediction give the same session. The frame rate and frame size are options the
+user gives; the files record neither.
+
+Not read: the multi-animal tracklet files (``*_el.h5`` / ``*_full.pickle`` side files, whose root
+group is ``tracks``) and pandas "fixed"-format files. Both are refused with a remediation.
+"""
+
+from __future__ import annotations
+
+import io
+import pickle
+from pathlib import Path
+from typing import Any, ClassVar
+
+import numpy as np
+
+from track2data.core.errors import ImportError_
+from track2data.core.ids import default_session_id
+from track2data.core.models import Session
+from track2data.readers.base import SessionReader
+from track2data.readers.deeplabcut import (
+ _NEEDED,
+ DeepLabCutReader,
+ _build_session,
+ _Header,
+ _parse_header,
+ _Table,
+)
+from track2data.readers.detection import Confidence, Detection, SessionCandidate
+from track2data.readers.index import ScanIndex
+from track2data.readers.params import ReaderParameter, resolve_options
+from track2data.readers.peek import Peeker
+
+#: The key DeepLabCut writes its prediction table under.
+_KEY = "df_with_missing"
+_SUFFIXES = (".h5", ".hdf5")
+#: The most a pickled attribute may be before it is refused unread (a hostile or damaged file).
+_MAX_LABEL_BYTES = 16 * 1024 * 1024
+_NOT_DLC_LABELS = "its column labels are not a (scorer, bodyparts, coords) table"
+
+
+def _error(code: str, message: str, remediation: str, subject: str = "") -> ImportError_:
+ return ImportError_(message, code=code, subject=subject, remediation=remediation)
+
+
+class _LabelUnpickler(pickle.Unpickler):
+ """Decodes label pickles and nothing else: no global (class, function) may be looked up."""
+
+ def find_class(self, module: str, name: str) -> Any:
+ raise pickle.UnpicklingError(f"{module}.{name} is not allowed in a label")
+
+
+def _labels(raw: Any, attribute: str, path: Path) -> Any:
+ """Decode one pickled label attribute, or raise ``DLCH5_LABELS_REFUSED``."""
+ data = bytes(raw)
+ refuse = _error(
+ "DLCH5_LABELS_REFUSED",
+ f"{path.name}: the column labels ({attribute}) are not plain labels, so the file was "
+ "not read.",
+ "Nothing was run. Export the prediction as CSV from DeepLabCut instead "
+ "(analyze_videos with save_as_csv=True, or deeplabcut.analyze_videos_converth5_to_csv).",
+ str(path),
+ )
+ if len(data) > _MAX_LABEL_BYTES:
+ raise refuse
+ try:
+ return _LabelUnpickler(io.BytesIO(data), encoding="utf-8").load()
+ except Exception: # a refused global, a truncated or non-pickle attribute
+ raise refuse from None
+
+
+def _the_pandas_group(handle: Any, path: Path) -> Any:
+ """The group holding the prediction table; refuses the layouts this reader does not take."""
+ import h5py
+
+ candidates = [
+ name
+ for name, obj in handle.items()
+ if isinstance(obj, h5py.Group)
+ and _text(obj.attrs.get("pandas_type")) == "frame_table"
+ and isinstance(obj.get("table"), h5py.Dataset)
+ ]
+ if not candidates:
+ if "tracks" in handle:
+ raise _error(
+ "DLCH5_LAYOUT_UNSUPPORTED",
+ f"{path.name} looks like a DeepLabCut multi-animal tracklet file (its root "
+ "group is 'tracks'), not a prediction table.",
+ "Use the prediction table DeepLabCut writes for the video, as CSV or as the "
+ "'.h5' that does not end in '_el' or '_full'.",
+ str(path),
+ )
+ raise _error(
+ "DLCH5_LAYOUT_UNSUPPORTED",
+ f"{path.name} does not hold a DeepLabCut prediction table "
+ f"(no pandas 'table' group such as '{_KEY}').",
+ "Point at the '.h5' prediction file DeepLabCut writes beside the video, or at its CSV.",
+ str(path),
+ )
+ name = _KEY if _KEY in candidates else candidates[0]
+ return handle[name]
+
+
+def _text(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ return str(value)
+
+
+def _load(path: Path) -> tuple[_Header, _Table]:
+ """Decode the table into the header and values the CSV path builds."""
+ import h5py
+
+ try:
+ handle = h5py.File(path, "r")
+ except OSError as exc:
+ raise _error(
+ "DLCH5_UNREADABLE",
+ f"{path.name} could not be opened as HDF5 ({exc}).",
+ "The file may be damaged or not finished writing; re-run the analysis in DeepLabCut.",
+ str(path),
+ ) from None
+ with handle:
+ group = _the_pandas_group(handle, path)
+ table = group["table"]
+ axes = _labels(group.attrs.get("non_index_axes", b""), "non_index_axes", path)
+ columns = _columns_of(axes, path)
+ if _text(table.attrs.get("index_kind")) != "integer":
+ raise _error(
+ "DLCH5_LAYOUT_UNSUPPORTED",
+ f"{path.name}: the row index is not a frame number.",
+ "Use the prediction file DeepLabCut writes for a video, not a labelled-data file.",
+ str(path),
+ )
+ try:
+ data = table[()]
+ frames = np.asarray(data["index"], dtype=np.int64)
+ values = np.full((len(frames), len(columns)), np.nan)
+ slot = {label: j for j, label in enumerate(columns)}
+ for field in data.dtype.names or ():
+ if not field.startswith("values_block_"):
+ continue
+ kind = _labels(table.attrs.get(f"{field}_kind", b""), f"{field}_kind", path)
+ block = np.asarray(data[field], dtype=np.float64).reshape(len(frames), -1)
+ if len(kind) != block.shape[1] or any(tuple(k) not in slot for k in kind):
+ raise _layout(path, "the value blocks do not match the column labels")
+ for i, label in enumerate(kind):
+ values[:, slot[tuple(label)]] = block[:, i]
+ except ImportError_:
+ raise
+ except Exception as exc:
+ raise _error(
+ "DLCH5_UNREADABLE",
+ f"{path.name}: the table could not be read ({exc}).",
+ "The file may be damaged; re-run the analysis in DeepLabCut.",
+ str(path),
+ ) from None
+ if (frames < 0).any() or len(np.unique(frames)) != len(frames):
+ raise _error(
+ "DLC_BAD_FRAME_INDEX",
+ f"{path.name}: the frame numbers must be non-negative and each appear once.",
+ "The file may be damaged or edited; re-run the analysis in DeepLabCut.",
+ str(path),
+ )
+ depth = len(columns[0])
+ header_columns = tuple(
+ (label[1] if depth == 4 else "", label[-2], label[-1].lower()) for label in columns
+ )
+ if not set(_NEEDED) <= {c[2] for c in header_columns}:
+ raise _layout(path, "it has no likelihood columns (an annotation file or a 3-D table)")
+ header = _Header(depth=depth, columns=header_columns, scorer=columns[0][0])
+ return header, _Table(frames, values)
+
+
+def _layout(path: Path, why: str) -> ImportError_:
+ return _error(
+ "DLCH5_LAYOUT_UNSUPPORTED",
+ f"{path.name} is not a DeepLabCut prediction table: {why}.",
+ "Point at the '.h5' prediction file DeepLabCut writes beside the video, or at its CSV.",
+ str(path),
+ )
+
+
+def _columns_of(axes: Any, path: Path) -> list[tuple[str, ...]]:
+ """The ``(scorer, [individual,] bodypart, coord)`` labels from ``non_index_axes``."""
+ try:
+ labels = next(items for axis, items in axes if axis == 1)
+ columns = [tuple(str(part) for part in label) for label in labels]
+ except (StopIteration, TypeError, ValueError):
+ raise _layout(path, _NOT_DLC_LABELS) from None
+ if not columns or len({len(c) for c in columns}) != 1 or len(columns[0]) not in (3, 4):
+ raise _layout(path, _NOT_DLC_LABELS)
+ return columns
+
+
+def _pandas_group_names(root: Any) -> list[str]:
+ return [] if root is None else sorted(root.pandas_tables)
+
+
+def _files_in(path: Path) -> list[Path]:
+ """The prediction files a path stands for: itself, or the ``.h5`` files directly inside."""
+ path = Path(path)
+ if path.is_file():
+ return [path] if _KEY in _pandas_group_names(Peeker().hdf5_root(path)) else []
+ if not path.is_dir():
+ return []
+ try:
+ candidates = sorted(
+ p for p in path.iterdir() if p.is_file() and p.suffix.lower() in _SUFFIXES
+ )
+ except OSError:
+ return []
+ return [p for p in candidates if _KEY in _pandas_group_names(Peeker().hdf5_root(p))]
+
+
+class DeepLabCutH5Reader(SessionReader):
+ """DeepLabCut prediction tables (HDF5): one session per file."""
+
+ name = "deeplabcut_h5"
+ display_name: ClassVar[str] = "DeepLabCut (HDF5)"
+ #: Built from the documented pandas layout; flips to "real_sample" once the real EPM file
+ #: has been read in a run (DECISIONS D-050).
+ verification = "synthetic_only"
+ parameters: ClassVar[tuple[ReaderParameter, ...]] = tuple(
+ # The keypoint names are pickled, so the scan cannot offer them: the user types one (or
+ # leaves it empty for the best-covered), and animals are all but DeepLabCut's catch-all.
+ spec.model_copy(update={"kind": "str"}) if spec.name == "keypoint" else spec
+ for spec in DeepLabCutReader.parameters
+ if spec.name != "individuals"
+ )
+
+ @classmethod
+ def detect(cls, folder: Path) -> bool:
+ return bool(_files_in(Path(folder)))
+
+ @classmethod
+ def discover(cls, index: ScanIndex, peek: Peeker) -> list[Detection]:
+ sessions: list[SessionCandidate] = []
+ for entry in index.walk():
+ if entry.is_dir or entry.suffix not in _SUFFIXES or entry.cloud_only:
+ continue
+ root = peek.hdf5_root(entry.path)
+ if _KEY not in _pandas_group_names(root):
+ continue
+ if _has_csv_twin(entry.path, index, peek):
+ continue # DeepLabCut writes both; the CSV is the one session
+ sessions.append(
+ SessionCandidate(
+ session_id=default_session_id(entry.path, is_file=True),
+ source=entry.path,
+ files=(entry.path,),
+ )
+ )
+ if not sessions:
+ return []
+ return [
+ Detection(
+ reader=cls.name,
+ display_name=cls.display_name,
+ # The labels are pickled and a scan never unpickles, so the table's shape is all
+ # that was checked: sure of the layout, not yet of the labels.
+ confidence=Confidence.MEDIUM,
+ evidence=(
+ f"{len(sessions)} HDF5 file(s) with a pandas table under '{_KEY}' "
+ "(DeepLabCut's prediction layout; labels are checked when read)",
+ ),
+ sessions=tuple(sessions),
+ parameters=cls.parameters,
+ verification=cls.verification,
+ )
+ ]
+
+ def read(self, folder: Path, *, allow_pickle: bool = False, options: Any = None) -> Session:
+ opts = resolve_options(self.name, self.parameters, options)
+ path = self._the_file(Path(folder))
+ header, table = _load(path)
+ return _build_session(
+ Path(folder),
+ path,
+ header,
+ table,
+ opts,
+ reader_name=self.name,
+ trajectory_format="deeplabcut_h5",
+ )
+
+ def _the_file(self, folder: Path) -> Path:
+ if folder.is_file():
+ return folder # _load says what is wrong with it
+ files = _files_in(folder)
+ if not files:
+ raise _error(
+ "DLCH5_NO_FILE",
+ f"No DeepLabCut .h5 prediction file at {folder}.",
+ "Point at a DeepLabCut .h5 file, or a folder that holds one.",
+ str(folder),
+ )
+ if len(files) > 1:
+ names = ", ".join(f.name for f in files)
+ raise _error(
+ "SESSION_AMBIGUOUS",
+ f"{folder} holds {len(files)} DeepLabCut files, one per video.",
+ f"Add one of them directly, or scan the folder to add them all: {names}.",
+ str(folder),
+ )
+ return files[0]
+
+
+def _has_csv_twin(path: Path, index: ScanIndex, peek: Peeker) -> bool:
+ """Whether a DeepLabCut CSV of the same name sits beside *path* (so the pair is one session)."""
+ twin = index.entry(path.with_suffix(".csv"))
+ if twin is None or twin.is_dir or twin.cloud_only:
+ return False
+ lines = peek.text_lines(twin.path, 5)
+ return lines is not None and _parse_header(lines) is not None
diff --git a/track2data/readers/idtrackerai/blobs.py b/track2data/readers/idtrackerai/blobs.py
index aa9ce313..6931e7dc 100644
--- a/track2data/readers/idtrackerai/blobs.py
+++ b/track2data/readers/idtrackerai/blobs.py
@@ -68,15 +68,33 @@ def __setstate__(self, state: Any) -> None:
self.__dict__["_state"] = state
+# Exact (module, name) pairs needed to reconstruct pickled ndarrays and numpy
+# scalars. numpy 1.x pickles under ``numpy.core``, numpy 2.x under
+# ``numpy._core``. A module-prefix match would also admit code-executing
+# helpers such as ``numpy.testing._private.utils.runstring``.
+_NUMPY_ALLOWLIST: frozenset[tuple[str, str]] = frozenset(
+ (mod, name)
+ for mod in ("numpy.core.multiarray", "numpy._core.multiarray")
+ for name in ("_reconstruct", "scalar")
+) | frozenset(
+ {
+ ("numpy", "ndarray"),
+ ("numpy", "dtype"),
+ ("numpy.core.numeric", "_frombuffer"),
+ ("numpy._core.numeric", "_frombuffer"),
+ }
+)
+
+
class _AllowlistUnpickler(pickle.Unpickler):
- """Refuses every class except idtrackerai.* (stubbed) and a small
+ """Refuses every class except idtrackerai.* (stubbed) and an exact
numpy allowlist needed to reconstruct the pickled arrays. More
restrictive than a bare pickle.load(), not less."""
def find_class(self, module: str, name: str) -> Any:
if module.startswith("idtrackerai"):
return _BlobStub
- if module.startswith("numpy"):
+ if (module, name) in _NUMPY_ALLOWLIST:
return super().find_class(module, name)
raise pickle.UnpicklingError(
f"Refusing to unpickle {module}.{name} (not in the allowlist)."
diff --git a/track2data/readers/idtrackerai_v5.py b/track2data/readers/idtrackerai_v5.py
index 0806ea80..2a17c75e 100644
--- a/track2data/readers/idtrackerai_v5.py
+++ b/track2data/readers/idtrackerai_v5.py
@@ -45,6 +45,7 @@
import json
import logging
+from collections.abc import Mapping
from pathlib import Path
from typing import Any, ClassVar, Literal
@@ -57,6 +58,7 @@
from track2data.readers.detection import Confidence, Detection, SessionCandidate
from track2data.readers.discovery import claim_sessions
from track2data.readers.index import ScanIndex
+from track2data.readers.params import ReaderParameter
from track2data.readers.peek import Peeker
logger = logging.getLogger(__name__)
@@ -158,7 +160,24 @@ def accept(folder: Path) -> SessionCandidate | None:
# ── read ──────────────────────────────────────────────────────────────────
- def read(self, folder: Path, *, allow_pickle: bool = False) -> Session:
+ #: Last resort only: video_object.npy, then the session json, win over it.
+ parameters: ClassVar[tuple[ReaderParameter, ...]] = (
+ ReaderParameter(
+ name="fps",
+ label="Frame rate (frames per second)",
+ kind="float",
+ minimum=0.001,
+ help="Used only when neither video_object.npy nor the session json records it.",
+ ),
+ )
+
+ def read(
+ self,
+ folder: Path,
+ *,
+ allow_pickle: bool = False,
+ options: Mapping[str, Any] | None = None,
+ ) -> Session:
"""Parse *folder* and return a Session.
The trajectory arrays here load with ``allow_pickle=False`` already;
@@ -176,7 +195,11 @@ def read(self, folder: Path, *, allow_pickle: bool = False) -> Session:
traj_path, variant = self._find_trajectory(folder)
raw_xy = self._load_trajectory(traj_path, n_frames_hint=n_frames_hint)
video_info = self._load_video_info(
- folder, raw_xy, video_obj=video_obj, allow_pickle=allow_pickle
+ folder,
+ raw_xy,
+ video_obj=video_obj,
+ allow_pickle=allow_pickle,
+ given_fps=(options or {}).get("fps"),
)
# The v5 session json was already being loaded elsewhere in this
# reader but track_wo_identities was never looked at, so a legacy
@@ -305,6 +328,7 @@ def _load_video_info(
video_obj: Any = None,
*,
allow_pickle: bool = False,
+ given_fps: Any = None,
) -> VideoInfo:
"""
Build VideoInfo from video_object.npy with fallback to session_progress.json.
@@ -318,22 +342,52 @@ def _load_video_info(
folder, allow_pickle=allow_pickle
)
- fps = float(_get_field(obj, "fps", "frames_per_second") or 1.0)
+ # None = not found. A recording genuinely at 1 fps is a legitimate value, so 1.0 must
+ # not double as the "missing" marker.
+ fps_raw = _get_field(obj, "fps", "frames_per_second")
+ fps = float(fps_raw) if fps_raw else None
height = int(_get_field(obj, "height", "original_height", "video_height") or 0)
width = int(_get_field(obj, "width", "original_width", "video_width") or 0)
raw_paths = _get_field(obj, "paths_to_video_files", "video_paths", "video_path")
# Fallback: session_progress.json for any missing fields
- if not height or not width or fps == 1.0:
+ if not height or not width or fps is None:
meta = self._load_session_json(folder)
if meta:
- if fps == 1.0:
- fps = float(_get_field(meta, "fps", "frames_per_second") or fps)
+ if fps is None:
+ fps_raw = _get_field(meta, "fps", "frames_per_second")
+ fps = float(fps_raw) if fps_raw else None
if not height:
height = int(_get_field(meta, "video_height", "height") or 0)
if not width:
width = int(_get_field(meta, "video_width", "width") or 0)
+ if fps is None and given_fps:
+ # The user's own answer, used only because the files hold none.
+ fps = float(given_fps)
+
+ if fps is None:
+ # Every speed, duration and time bin scales with the frame rate; guessing one
+ # would export wrong numbers that look valid.
+ raise DataValidationError(
+ "The frame rate of this session could not be found in "
+ f"{_VIDEO_OBJ_NAME} or the session json.",
+ code="VIDEO_FPS_MISSING",
+ subject=str(folder),
+ remediation=(
+ "Give the frame rate (the fps option: --option fps=25 on the command "
+ "line, or the Frame rate field when importing), allow reading "
+ "video_object.npy (security.allow_pickle_trajectories), or make sure the "
+ "session json is in the folder."
+ ),
+ )
+ if not height or not width:
+ logger.warning(
+ "The frame size of %s is unknown; zone and panel checks that need it are "
+ "skipped.",
+ folder,
+ )
+
# Resolve video path (ASSUMPTION 5 — may be unreachable)
video_path: Path | None = None
if raw_paths:
diff --git a/track2data/readers/peek.py b/track2data/readers/peek.py
index bd058514..62803e5d 100644
--- a/track2data/readers/peek.py
+++ b/track2data/readers/peek.py
@@ -123,6 +123,10 @@ class Hdf5Root:
nodes: dict[str, Hdf5Node]
strings: dict[str, list[str]] = field(default_factory=dict)
+ #: Top-level groups that pandas wrote with ``format="table"`` (their ``pandas_type`` attribute,
+ #: plain text, is ``frame_table``), as ``{group: (rows, value_columns)}``. Only that marker is
+ #: read: the column labels are pickled and a peek never unpickles anything.
+ pandas_tables: dict[str, tuple[int, int]] = field(default_factory=dict)
#: A string dataset longer than this is not read by a peek.
@@ -200,6 +204,7 @@ def hdf5_root(self, path: Path, strings: tuple[str, ...] = ()) -> Hdf5Root | Non
with h5py.File(path, "r") as handle:
nodes: dict[str, Hdf5Node] = {}
+ tables: dict[str, tuple[int, int]] = {}
for name, obj in handle.items():
if isinstance(obj, h5py.Dataset):
nodes[name] = Hdf5Node(
@@ -210,6 +215,9 @@ def hdf5_root(self, path: Path, strings: tuple[str, ...] = ()) -> Hdf5Root | Non
)
else:
nodes[name] = Hdf5Node("group")
+ marker = _pandas_table(obj)
+ if marker is not None:
+ tables[name] = marker
found: dict[str, list[str]] = {}
for name in strings:
node = handle.get(name)
@@ -221,7 +229,7 @@ def hdf5_root(self, path: Path, strings: tuple[str, ...] = ()) -> Hdf5Root | Non
found[name] = [_text(v) for v in node[:]]
except Exception: # not HDF5, truncated, permissions: cannot tell
return None
- return Hdf5Root(nodes, found)
+ return Hdf5Root(nodes, found, tables)
def mat_variables(self, path: Path) -> dict[str, tuple[tuple[int, ...], str]] | None:
"""The variables of a classic (v5) MATLAB file as ``{name: (shape, class)}``; else None.
@@ -259,6 +267,24 @@ def npy_header(self, path: Path) -> NpyHeader | None:
return NpyHeader(tuple(shape), dtype, bool(fortran_order))
+def _pandas_table(group: Any) -> tuple[int, int] | None:
+ """``(rows, value columns)`` of a pandas ``format="table"`` group, else None. Reads the
+ plain-text ``pandas_type`` attribute and the table's dtype only."""
+ try:
+ if _attr_value(group.attrs.get("pandas_type")) != "frame_table":
+ return None
+ table = group.get("table")
+ names = table.dtype.names if table is not None else None
+ if not names or table.ndim != 1:
+ return None
+ columns = sum(
+ int(np.prod(table.dtype[n].shape)) or 1 for n in names if n.startswith("values_block_")
+ )
+ return int(table.shape[0]), columns
+ except Exception: # an odd group is just not a pandas table
+ return None
+
+
def _text(value: Any) -> str:
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
diff --git a/track2data/readers/scan.py b/track2data/readers/scan.py
index 89b8ea61..57067ba6 100644
--- a/track2data/readers/scan.py
+++ b/track2data/readers/scan.py
@@ -14,7 +14,7 @@
import dataclasses
from collections.abc import Mapping, Sequence
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
from track2data.core.ids import sanitise_session_id, uniquify
@@ -50,6 +50,14 @@ class ScanResult:
seconds: float
#: Formats named but not readable (so "nothing to add" can say what the files are).
recognised: tuple[Recognised, ...] = ()
+ #: The walk behind the result, kept so a reader can be tried again leniently
+ #: (:func:`lenient_scan`). Not part of the result's identity; the confirm dialog drops it with
+ #: :meth:`release_index` when it closes, and it is never stored on a manifest.
+ index: ScanIndex | None = field(default=None, repr=False, compare=False)
+
+ def release_index(self) -> None:
+ """Let go of the retained walk (a large tree is a lot of memory to hold for nothing)."""
+ object.__setattr__(self, "index", None)
def scan(
@@ -99,9 +107,49 @@ def scan_index(
entries=len(index),
seconds=index.seconds,
recognised=find_unreadable(index, peek, claimed),
+ index=index,
)
+def lenient_readers() -> list[str]:
+ """Names of the readers that can try again with looser rules (they override
+ ``discover_lenient``), in registry order."""
+ from track2data.readers import _REGISTRY
+ from track2data.readers.base import SessionReader
+
+ base = SessionReader.discover_lenient.__func__ # type: ignore[attr-defined]
+ return [
+ cls.name
+ for cls in _REGISTRY
+ if getattr(cls.discover_lenient, "__func__", None) is not base
+ ]
+
+
+def lenient_scan(result: ScanResult, reader_name: str) -> FormatGroup | None:
+ """Re-run one reader's loosened discovery on the walk *result* kept.
+
+ Returns a group of LOW-confidence detections, or None when the reader is unknown, has no
+ lenient rules, finds nothing, or the index was released. Never raises. A candidate still goes
+ through ``read``, so a wrong guess fails with the reader's own coded error.
+ """
+ from track2data.readers import find_reader
+
+ cls = find_reader(reader_name)
+ if cls is None or result.index is None:
+ return None
+ try:
+ found = cls.discover_lenient(result.index, Peeker(None, result.index))
+ except Exception: # a reader must never break a retry
+ return None
+ if not found:
+ return None
+ found = [
+ _with_unique_ids(dataclasses.replace(d, confidence=Confidence.LOW), result.index.roots)
+ for d in found
+ ]
+ return FormatGroup(tuple(found))
+
+
def _prune_nested(detections: list[Detection], index: ScanIndex) -> list[Detection]:
"""Drop weaker claims that sit inside a folder another reader is sure about.
diff --git a/track2data/readers/sleap_analysis.py b/track2data/readers/sleap_analysis.py
index b14f8518..b5df0dfc 100644
--- a/track2data/readers/sleap_analysis.py
+++ b/track2data/readers/sleap_analysis.py
@@ -48,6 +48,8 @@
_MARKERS = ("track_names", "node_names", "track_occupancy")
_SUFFIXES = (".h5", ".hdf5")
+#: Other names an analysis export is saved under (the choose-software retry).
+_LENIENT_SUFFIXES = frozenset({"", ".analysis", ".hdf", ".dat"})
_SYNTHETIC = re.compile(r"^track_\d+$")
_STANDARD_DIMS = ["track", "xy", "node", "frame"]
@@ -210,6 +212,43 @@ def discover(cls, index: ScanIndex, peek: Peeker) -> list[Detection]:
)
]
+ @classmethod
+ def discover_lenient(cls, index: ScanIndex, peek: Peeker) -> list[Detection]:
+ """Analysis files saved without an ``.h5`` / ``.hdf5`` extension.
+
+ The file must still have every analysis marker, so only the extension is loosened.
+ """
+ sessions = []
+ for entry in index.walk():
+ if entry.is_dir or entry.suffix not in _LENIENT_SUFFIXES or entry.cloud_only:
+ continue
+ root = peek.hdf5_root(entry.path, strings=("node_names", "track_names"))
+ if _is_analysis(root):
+ sessions.append(
+ SessionCandidate(
+ session_id=_session_id(entry.path),
+ source=entry.path,
+ files=(entry.path,),
+ warnings=_warnings_for(root), # type: ignore[arg-type]
+ )
+ )
+ if not sessions:
+ return []
+ return [
+ Detection(
+ reader=cls.name,
+ display_name=cls.display_name,
+ confidence=Confidence.LOW,
+ evidence=(
+ f"{len(sessions)} HDF5 file(s) with the SLEAP analysis layout "
+ "but not an .h5 / .hdf5 extension",
+ ),
+ sessions=tuple(sessions),
+ parameters=cls.parameters,
+ verification=cls.verification,
+ )
+ ]
+
# ── reading ────────────────────────────────────────────────────────────
def read(self, folder: Path, *, allow_pickle: bool = False, options: Any = None) -> Session:
diff --git a/track2data/readers/video_meta.py b/track2data/readers/video_meta.py
index 18b39c49..dee28c6b 100644
--- a/track2data/readers/video_meta.py
+++ b/track2data/readers/video_meta.py
@@ -1,5 +1,5 @@
"""
-Video frame extraction for the preview layer.
+Video frame extraction for the preview layer, and a metadata probe for the import dialog.
Uses pyav (optional dependency) to extract a single frame from the source
video. If pyav is not installed or the video file is unreachable the
@@ -8,12 +8,19 @@
from __future__ import annotations
+import math
+from dataclasses import dataclass
from pathlib import Path
-def extract_frame(video_path: Path, frame_index: int = 0) -> bytes | None:
+def extract_frame_rgb(video_path: Path, frame_index: int = 0) -> tuple[bytes, int, int] | None:
"""
- Return raw RGB bytes for *frame_index* or None on any failure.
+ Return ``(rgb_bytes, width, height)`` for *frame_index*, with the size the decoder itself
+ reports for that frame, or None on any failure.
+
+ The size matters to a caller that draws the frame behind coordinates stored in the
+ video's declared pixel space: a rotated frame has the same byte count as the declared one,
+ so the length alone cannot tell them apart.
Requires the optional 'video' extra (pip install track2data[video]).
"""
try:
@@ -27,7 +34,54 @@ def extract_frame(video_path: Path, frame_index: int = 0) -> bytes | None:
stream.codec_context.skip_frame = "NONREF"
for i, frame in enumerate(container.decode(stream)):
if i == frame_index:
- return bytes(frame.to_image().tobytes())
+ image = frame.to_image()
+ return bytes(image.tobytes()), int(image.width), int(image.height)
except Exception:
return None
return None
+
+
+def extract_frame(video_path: Path, frame_index: int = 0) -> bytes | None:
+ """
+ Return raw RGB bytes for *frame_index* or None on any failure.
+ Requires the optional 'video' extra (pip install track2data[video]).
+ """
+ frame = extract_frame_rgb(video_path, frame_index)
+ return None if frame is None else frame[0]
+
+
+@dataclass(frozen=True)
+class VideoProbe:
+ """What a video file says about itself: enough to fill in what a tracker's file leaves out."""
+
+ fps: float
+ width_px: int
+ height_px: int
+ #: None when the container does not record a frame count.
+ n_frames: int | None = None
+
+
+def probe_video(video_path: Path) -> VideoProbe | None:
+ """Read the frame rate and frame size of the first video stream of *video_path*.
+
+ None when the optional 'video' extra is missing, the file cannot be opened, or the stream
+ reports no usable frame rate or size. Never raises. This is for a file the user chose in
+ the import dialog: scanning never opens a video.
+ """
+ try:
+ import av
+ except ImportError:
+ return None
+
+ try:
+ with av.open(str(video_path)) as container:
+ stream = container.streams.video[0]
+ rate = stream.average_rate or stream.guessed_rate
+ fps = float(rate) if rate else 0.0
+ width, height = int(stream.width or 0), int(stream.height or 0)
+ frames = int(stream.frames or 0)
+ except Exception:
+ return None
+ if not (math.isfinite(fps) and fps > 0 and width > 0 and height > 0):
+ return None
+ return VideoProbe(fps=fps, width_px=width, height_px=height, n_frames=frames or None)
diff --git a/track2data/views/panels.py b/track2data/views/panels.py
index e21c932c..8e0beb5c 100644
--- a/track2data/views/panels.py
+++ b/track2data/views/panels.py
@@ -213,12 +213,21 @@ def keep_animals(session: Session, keep: list[int]) -> Session:
def apply_panel(session: Session, rect: PanelRect) -> Session:
"""Return the session cut to ``rect``, in panel coordinates (see the module docstring).
- Raises ValueError when the panel does not fit inside the video.
+ Raises ValueError when the panel does not fit inside the video, or when no animal has
+ enough of its positions inside it (a session of zero animals would only fail later, with an
+ unrelated shape error or empty tables).
"""
cov = panel_coverage(session, rect) # also checks that the panel fits
_, inside = _masks(session.raw_xy, rect)
stable = session.has_stable_identities
keep = [c.index for c in cov if is_kept(c, stable)]
+ if not keep:
+ raise ValueError(
+ f"No animal has enough of its positions inside the panel (x {rect.x:g}, "
+ f"y {rect.y:g}, {rect.width:g} x {rect.height:g}); at least "
+ f"{round(MIN_COVERAGE * 100)}% of a tracked animal's positions must fall in it. "
+ "Move or resize the panel, or check that it is placed on the right half of the video."
+ )
if stable:
left_out = [c.label for c in cov if not is_kept(c, stable)]
if left_out:
diff --git a/track2data/zones/io.py b/track2data/zones/io.py
index 4242338a..6da51c92 100644
--- a/track2data/zones/io.py
+++ b/track2data/zones/io.py
@@ -9,7 +9,10 @@
# ── public API ────────────────────────────────────────────────────────────────
-_CSV_HEADER = ("roi_name", "level", "vertex_x", "vertex_y")
+_CSV_HEADER = (
+ "roi_name", "level", "vertex_x", "vertex_y",
+ "sign", "polygon", "source_width_px", "source_height_px",
+)
def load_zones_csv(path: Path) -> ZoneSet:
@@ -17,10 +20,17 @@ def load_zones_csv(path: Path) -> ZoneSet:
The CSV must have a header row followed by one row per vertex::
- roi_name, level, vertex_x, vertex_y
+ roi_name, level, vertex_x, vertex_y[, sign, polygon, source_width_px, source_height_px]
- Rows are grouped by ``roi_name``; vertex order is preserved. An empty
- CSV (header only) returns an empty ``ZoneSet``.
+ The first four columns are required; the rest are written by
+ :func:`save_zones_csv` and optional on input, so older files still load.
+ ``sign`` ("+" additive or "-" subtractive hole, default "+") and ``polygon``
+ (an id shared by the rows of one polygon) keep same-named ROIs apart: an arena
+ outline and an exclusion hole may share the name ``arena`` and must stay two
+ polygons. Without ``polygon``, rows are grouped by ``(roi_name, sign)``. Vertex
+ order is preserved. ``source_width_px`` / ``source_height_px`` restore the
+ resolution the zones were drawn against. An empty CSV (header only) returns an
+ empty ``ZoneSet``.
Parameters
----------
@@ -33,6 +43,11 @@ def load_zones_csv(path: Path) -> ZoneSet:
Parsed zone set. ``orientation_tag`` and ``zone_levels`` are left at
their defaults because the CSV format does not store them.
+ Raises
+ ------
+ ValueError
+ If a ``sign`` is neither "+" nor "-".
+
Raises
------
FileNotFoundError
@@ -42,27 +57,35 @@ def load_zones_csv(path: Path) -> ZoneSet:
if not path.exists():
raise FileNotFoundError(f"Zone CSV not found: {path}")
- # Preserve insertion order for ROI names.
- roi_vertices: dict[str, list[tuple[float, float]]] = {}
- roi_levels: dict[str, str] = {}
+ # Preserve insertion order of the polygons.
+ polygons: dict[tuple[str, str], dict] = {}
+ source_size: list[int | None] = [None, None]
+
+ def _optional_int(text: str | None) -> int | None:
+ text = (text or "").strip()
+ return int(float(text)) if text else None
with path.open(newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
for row in reader:
name = row["roi_name"].strip()
level = row["level"].strip()
- x = float(row["vertex_x"])
- y = float(row["vertex_y"])
- if name not in roi_vertices:
- roi_vertices[name] = []
- roi_levels[name] = level
- roi_vertices[name].append((x, y))
-
- rois = [
- ROI(name=name, level=roi_levels[name], vertices=verts)
- for name, verts in roi_vertices.items()
- ]
- return ZoneSet(rois=rois)
+ sign = (row.get("sign") or "+").strip() or "+"
+ if sign not in ("+", "-"):
+ raise ValueError(f"Zone CSV sign must be '+' or '-', got {sign!r} for {name!r}.")
+ polygon = (row.get("polygon") or "").strip()
+ key = (name, polygon if polygon else sign)
+ if key not in polygons:
+ polygons[key] = {"name": name, "level": level, "sign": sign, "vertices": []}
+ polygons[key]["vertices"].append((float(row["vertex_x"]), float(row["vertex_y"])))
+ for i, column in enumerate(("source_width_px", "source_height_px")):
+ if source_size[i] is None:
+ source_size[i] = _optional_int(row.get(column))
+
+ rois = [ROI(**fields) for fields in polygons.values()]
+ return ZoneSet(
+ rois=rois, source_width_px=source_size[0], source_height_px=source_size[1]
+ )
def save_zones_csv(zone_set: ZoneSet, path: Path) -> None:
@@ -70,7 +93,10 @@ def save_zones_csv(zone_set: ZoneSet, path: Path) -> None:
One row is written per vertex. The CSV header is::
- roi_name,level,vertex_x,vertex_y
+ roi_name,level,vertex_x,vertex_y,sign,polygon,source_width_px,source_height_px
+
+ ``polygon`` numbers the ROIs in order, so same-named ROIs (an outline and its
+ holes) round-trip as separate polygons with their ``sign``.
Parameters
----------
@@ -80,12 +106,14 @@ def save_zones_csv(zone_set: ZoneSet, path: Path) -> None:
Destination path. Parent directories must already exist.
"""
path = Path(path)
+ width = "" if zone_set.source_width_px is None else zone_set.source_width_px
+ height = "" if zone_set.source_height_px is None else zone_set.source_height_px
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(_CSV_HEADER)
- for roi in zone_set.rois:
+ for polygon, roi in enumerate(zone_set.rois):
for x, y in roi.vertices:
- writer.writerow([roi.name, roi.level, x, y])
+ writer.writerow([roi.name, roi.level, x, y, roi.sign, polygon, width, height])
def zone_set_from_dict(data: dict) -> ZoneSet:
diff --git a/track2data/zones/orientation.py b/track2data/zones/orientation.py
index 210acb13..d7c1f062 100644
--- a/track2data/zones/orientation.py
+++ b/track2data/zones/orientation.py
@@ -75,6 +75,7 @@ def flip_orientation(zone_set: ZoneSet) -> ZoneSet:
level=roi.level,
vertices=list(reversed(roi.vertices)),
area_units=roi.area_units,
+ sign=roi.sign,
)
for roi in zone_set.rois
]
@@ -82,6 +83,8 @@ def flip_orientation(zone_set: ZoneSet) -> ZoneSet:
rois=new_rois,
orientation_tag=new_tag,
zone_levels=dict(zone_set.zone_levels),
+ source_width_px=zone_set.source_width_px,
+ source_height_px=zone_set.source_height_px,
)
diff --git a/ui/calibration_screen.py b/ui/calibration_screen.py
index bbf00bc8..d982c607 100644
--- a/ui/calibration_screen.py
+++ b/ui/calibration_screen.py
@@ -47,7 +47,7 @@
from ui.widgets.autocommit import AutoCommit
from ui.widgets.mode_banner import ModeBanner
-_UNIT_CHOICES = ["cm", "mm", "m"]
+_UNIT_CHOICES = ["cm", "mm", "m", "in"] # in: native 3-D files can arrive in inches
_IDT_INFO = (
"Uses each session's own calibration ratio, recorded by the "
@@ -546,8 +546,10 @@ def _populate(self) -> None:
self._px_spin.setValue(cfg.px_per_cm)
elif cfg.mode == "session":
self._radio_session.setChecked(True)
- if cfg.length_unit_label in _UNIT_CHOICES:
- self._unit_combo.setCurrentText(cfg.length_unit_label)
+ if self._unit_combo.findText(cfg.length_unit_label) < 0:
+ # A label set elsewhere must never be silently replaced by Apply.
+ self._unit_combo.addItem(cfg.length_unit_label)
+ self._unit_combo.setCurrentText(cfg.length_unit_label)
self._stat_combo.setCurrentIndex(
max(0, self._stat_combo.findData(cfg.session_calibration_stat))
)
diff --git a/ui/dialogs/confirm_format_dialog.py b/ui/dialogs/confirm_format_dialog.py
index 09a2e8fd..de9e4ae5 100644
--- a/ui/dialogs/confirm_format_dialog.py
+++ b/ui/dialogs/confirm_format_dialog.py
@@ -20,6 +20,7 @@
from __future__ import annotations
+from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt
@@ -27,9 +28,11 @@
QAbstractItemView,
QComboBox,
QDialog,
+ QFileDialog,
QHBoxLayout,
QHeaderView,
QLabel,
+ QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
@@ -41,6 +44,7 @@
from track2data.readers import get_reader, reader_names
from track2data.readers.confirm import ConfirmDraft
from track2data.readers.detection import Confidence
+from track2data.readers.scan import lenient_readers
from ui.widgets.dots import level_colour
from ui.widgets.reader_options_form import ReaderOptionsForm
@@ -116,6 +120,19 @@ def _build(self) -> None:
self.empty_label.setTextFormat(Qt.TextFormat.RichText)
root.addWidget(self.empty_label)
+ # "Choose software…": when nothing was recognised, let the user name a reader to try with
+ # looser rules (readers/scan.lenient_scan). Shown only in that state.
+ self._lenient_row = QWidget()
+ lenient_layout = QHBoxLayout(self._lenient_row)
+ lenient_layout.setContentsMargins(0, 0, 0, 0)
+ lenient_layout.addWidget(QLabel("Choose software…"))
+ self.lenient_combo = QComboBox()
+ lenient_layout.addWidget(self.lenient_combo, 1)
+ self.lenient_button = QPushButton("Try this software")
+ self.lenient_button.clicked.connect(self._on_lenient_clicked)
+ lenient_layout.addWidget(self.lenient_button)
+ root.addWidget(self._lenient_row)
+
self._content = QWidget()
content = QVBoxLayout(self._content)
content.setContentsMargins(0, 0, 0, 0)
@@ -151,6 +168,18 @@ def _build(self) -> None:
self._options_holder.addWidget(self._options_form)
content.addLayout(self._options_holder)
+ probe_row = QHBoxLayout()
+ self.probe_button = QPushButton("Read from video…")
+ self.probe_button.setProperty("role", "outline")
+ self.probe_button.setAutoDefault(False)
+ self.probe_button.setToolTip(
+ "Pick the recorded video to fill in its frame rate and frame size."
+ )
+ self.probe_button.clicked.connect(self._on_probe_clicked)
+ probe_row.addWidget(self.probe_button)
+ probe_row.addStretch()
+ content.addLayout(probe_row)
+
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["", "Session", "Source", "Note"])
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
@@ -202,6 +231,7 @@ def _sync_all(self) -> None:
f"Scanned {result.entries} entries in {result.seconds:.1f} s: {roots}"
)
self.empty_label.hide()
+ self._lenient_row.hide()
self._content.show()
self.ok_button.show()
self.cancel_button.setText("Cancel")
@@ -226,10 +256,36 @@ def _show_nothing_recognised(self, roots: str) -> None:
lines.append("Folders are searched four levels deep.")
self.empty_label.setText("
".join(lines))
self.empty_label.show()
+ self._sync_lenient_row()
self._content.hide()
self.ok_button.hide()
self.cancel_button.setText("Close")
+ def _sync_lenient_row(self) -> None:
+ names = lenient_readers() if self._draft.result.index is not None else []
+ self.lenient_combo.clear()
+ for name in names:
+ self.lenient_combo.addItem(get_reader(name).display_name or name, name)
+ self._lenient_row.setVisible(bool(names))
+
+ def _on_lenient_clicked(self) -> None:
+ name = self.lenient_combo.currentData()
+ if not name:
+ return
+ if self._draft.offer_reader(name):
+ self._sync_all()
+ return
+ label = self.lenient_combo.currentText()
+ self.empty_label.setText(
+ self.empty_label.text()
+ + f"
Nothing here could be read as {label}, even with looser rules."
+ )
+
+ def done(self, result: int) -> None:
+ # The scan's walk is only kept for the retry above; do not hold a large tree after close.
+ self._draft.result.release_index()
+ super().done(result)
+
def _sync_groups(self) -> None:
groups = self._draft.groups
self._syncing = True
@@ -282,6 +338,26 @@ def _rebuild_options(self) -> None:
self._options_form.set_values(draft.options, sources)
self._options_form.valueChanged.connect(self._on_option_edited)
self._options_form.setVisible(not self._options_form.is_empty)
+ self.probe_button.setEnabled(draft.accepts_video_probe)
+
+ def _on_probe_clicked(self) -> None:
+ path, _filter = QFileDialog.getOpenFileName(
+ self, "Choose the recorded video", "", "Video files (*.mp4 *.avi *.mov *.mkv *.m4v)"
+ )
+ if not path:
+ return
+ probe = self._draft.prefill_from_video(Path(path))
+ if probe is None:
+ QMessageBox.warning(
+ self,
+ "Read from video",
+ "The frame rate and size could not be read from that file. Reading videos needs "
+ "the optional video support (pip install track2data[video]); otherwise type the "
+ "values in.",
+ )
+ return
+ self._rebuild_options()
+ self._update_ok()
def _fill_table(self) -> None:
rows = self._draft.rows
diff --git a/ui/export_screen.py b/ui/export_screen.py
index 6476e9aa..bb86b16a 100644
--- a/ui/export_screen.py
+++ b/ui/export_screen.py
@@ -306,6 +306,21 @@ def _resolved_out_dir(self) -> Path:
return Path(self._output_dir)
return self._default_dir()
+ def _refresh_output_dir_after_run(self) -> None:
+ """Re-arm the overwrite guard once a run has (possibly) written into the directory.
+
+ The guard otherwise only ran when the directory was chosen or the project changed, so a
+ second Export went to the same, now populated, directory without asking. The default
+ timestamped directory moves on to a fresh name; a directory the user chose stays, and
+ now lists what the first run wrote and asks before it is replaced.
+ """
+ if not self._output_dir:
+ self._default_dir_cache = None
+ has_project = self._store is not None and self._store.has_project
+ if has_project:
+ self._dir_label.setText(str(self._resolved_out_dir()))
+ self._check_overwrite()
+
def _check_overwrite(self) -> None:
out_dir = self._resolved_out_dir()
existing: list[str] = []
@@ -417,7 +432,7 @@ def _on_task_finished(self, task_id: str, result: object) -> None:
if task_id != self._current_task_id:
return
self._current_task_id = None
- self._update_export_enabled()
+ self._refresh_output_dir_after_run()
if isinstance(result, RunResult):
accepted = self._store.set_run_results(
@@ -449,7 +464,7 @@ def _on_task_cancelled(self, task_id: str) -> None:
if task_id != self._current_task_id:
return
self._current_task_id = None
- self._update_export_enabled()
+ self._refresh_output_dir_after_run()
self._status_label.setText("Cancelled.")
if self._store is not None:
self._store.append_log("### Export cancelled\n")
diff --git a/ui/metadata_screen.py b/ui/metadata_screen.py
index f56d57af..16427462 100644
--- a/ui/metadata_screen.py
+++ b/ui/metadata_screen.py
@@ -34,6 +34,7 @@
from track2data.core.hashing import file_sha256
from track2data.core.models import MappingRule, MetadataSource
+from track2data.metadata.loader import list_sheets, preview
from track2data.metadata.schema import CANONICAL, resolve_column
from ui.widgets.autocommit import AutoCommit
from ui.widgets.labels import label_for
@@ -99,7 +100,7 @@ def _build_ui(self) -> None:
# ── file row ──────────────────────────────────────────────────────
file_row = QHBoxLayout()
- load_btn = QPushButton("Load metadata CSV…")
+ load_btn = QPushButton("Load metadata file…")
load_btn.setProperty("role", "accent")
load_btn.clicked.connect(self._load_csv)
skip_btn = QPushButton("Skip metadata")
@@ -112,6 +113,20 @@ def _build_ui(self) -> None:
file_row.addWidget(self._file_label, 1)
root.addLayout(file_row)
+ # A workbook with several sheets needs to be told which one holds the metadata; the row
+ # is shown only then (a CSV or a one-sheet workbook has nothing to choose).
+ self._sheet_row = QWidget()
+ sheet_layout = QHBoxLayout(self._sheet_row)
+ sheet_layout.setContentsMargins(0, 0, 0, 0)
+ sheet_layout.addWidget(QLabel("Sheet:"))
+ self._sheet_combo = QComboBox()
+ self._sheet_combo.currentIndexChanged.connect(weak_slot(self._on_sheet_chosen))
+ sheet_layout.addWidget(self._sheet_combo, 1)
+ self._sheet_row.hide()
+ self._shown_path: Path | None = None
+ self._shown_sheet: str | None = None
+ root.addWidget(self._sheet_row)
+
# ── preview table ─────────────────────────────────────────────────
self._preview = QTableWidget(0, 0)
self._preview.setMinimumHeight(130)
@@ -177,39 +192,60 @@ def _set_match_role(self, role: str) -> None:
def _load_csv(self) -> None:
path, _ = QFileDialog.getOpenFileName(
- self, "Load metadata CSV", "", "CSV files (*.csv);;All files (*)"
+ self,
+ "Load metadata file",
+ "",
+ "Metadata files (*.csv *.xlsx);;CSV files (*.csv);;Excel workbooks (*.xlsx);;"
+ "All files (*)",
)
if path:
self.load_path(Path(path))
- def load_path(self, path: Path) -> None:
- """Load *path* as the project's metadata file (what the dialog calls)."""
+ def load_path(self, path: Path, sheet: str | None = None) -> None:
+ """Load *path* as the project's metadata file (what the dialog calls).
+
+ *sheet* picks a sheet of an Excel workbook; the first one when None."""
try:
- import csv
- rows: list[list[str]] = []
- with open(path, newline="", encoding="utf-8-sig") as fh:
- reader = csv.reader(fh)
- for i, row in enumerate(reader):
- rows.append(row)
- if i >= 5: # header + 5 data rows
- break
- if not rows:
- QMessageBox.warning(self, "Empty file", "The CSV file appears to be empty.")
+ sheets = list_sheets(path)
+ chosen = sheet if sheet is not None else (sheets[0] if sheets else None)
+ headers, data_rows = preview(path, chosen)
+ if not headers:
+ QMessageBox.warning(self, "Empty file", "The metadata file appears to be empty.")
return
- headers = rows[0]
- data_rows = rows[1:]
self._columns = headers
self._populate_preview(headers, data_rows)
self._populate_combos(headers)
self._file_label.setText(Path(path).name)
self._file_label.setProperty("role", "")
+ stored = chosen if len(sheets) > 1 else None
+ self._show_sheets(Path(path), sheets, stored)
if self._store is not None:
- csv_path = Path(path)
self._store.update_metadata_source(
- MetadataSource(path=csv_path, sha256=file_sha256(csv_path))
+ MetadataSource(path=Path(path), sha256=file_sha256(Path(path)), sheet=stored)
)
except Exception as exc:
- QMessageBox.critical(self, "Error", f"Failed to load CSV:\n{exc}")
+ QMessageBox.critical(self, "Error", f"Failed to load the metadata file:\n{exc}")
+
+ def _show_sheets(self, path: Path, sheets: list[str], chosen: str | None) -> None:
+ """Offer the workbook's sheets (only when there is more than one) without reloading."""
+ self._shown_path = path
+ self._shown_sheet = chosen
+ self._sheet_combo.blockSignals(True)
+ try:
+ self._sheet_combo.clear()
+ self._sheet_combo.addItems(sheets if len(sheets) > 1 else [])
+ if chosen is not None:
+ self._sheet_combo.setCurrentIndex(max(self._sheet_combo.findText(chosen), 0))
+ finally:
+ self._sheet_combo.blockSignals(False)
+ self._sheet_row.setVisible(len(sheets) > 1)
+
+ def _on_sheet_chosen(self) -> None:
+ """The user picked another sheet: reload the preview and mapping from it."""
+ sheet = self._sheet_combo.currentText()
+ if self._shown_path is None or not sheet or sheet == self._shown_sheet:
+ return
+ self.load_path(self._shown_path, sheet)
def _populate_preview(self, headers: list[str], rows: list[list[str]]) -> None:
self._preview.setColumnCount(len(headers))
@@ -312,7 +348,9 @@ def _on_metadata_changed(self) -> None:
if src is None:
if self._file_label.text() not in ("(skipped)",):
self._file_label.setText("(no file loaded)")
- elif self._file_label.text() != src.path.name and Path(src.path).exists():
+ elif (
+ self._file_label.text() != src.path.name or self._shown_sheet != src.sheet
+ ) and Path(src.path).exists():
self._restore_from_source(src)
self._sync_combos_to_mapping()
self._refresh_match_summary()
@@ -334,18 +372,17 @@ def _sync_combos_to_mapping(self) -> None:
self._match_mode.setCurrentIndex(mode_idx)
def _restore_from_source(self, src: MetadataSource) -> None:
- import csv
-
- with open(src.path, newline="", encoding="utf-8-sig") as fh:
- rows = [row for _, row in zip(range(6), csv.reader(fh), strict=False)]
- if not rows:
+ sheets = list_sheets(src.path)
+ headers, data_rows = preview(src.path, src.sheet)
+ if not headers:
return
- self._columns = rows[0]
- self._populate_preview(rows[0], rows[1:])
+ self._columns = headers
+ self._populate_preview(headers, data_rows)
with self._auto.suppressed():
- self._fill_combos(rows[0])
+ self._fill_combos(headers)
self._file_label.setText(src.path.name)
self._file_label.setProperty("role", "")
+ self._show_sheets(src.path, sheets, src.sheet)
def _per_animal_notes(self, manifest, result) -> list[str]:
"""Problems with per-animal rows that the engine will only log: animals
@@ -399,7 +436,9 @@ def _refresh_match_summary(self) -> None:
from track2data.metadata.loader import load
from track2data.metadata.mapping import apply_mapping
- mapped = apply_mapping(load(m.metadata_source.path), m.mapping)
+ mapped = apply_mapping(
+ load(m.metadata_source.path, m.metadata_source.sheet), m.mapping
+ )
result = match([r.session_id for r in m.sessions], mapped, m.mapping)
except Exception as exc:
self._match_label.setText(f"Could not match sessions: {exc}")
diff --git a/ui/processing_screen.py b/ui/processing_screen.py
index 16ac940f..a3233136 100644
--- a/ui/processing_screen.py
+++ b/ui/processing_screen.py
@@ -23,6 +23,7 @@
from __future__ import annotations
import functools
+import shlex
from datetime import UTC, datetime
from pathlib import Path
@@ -385,7 +386,12 @@ def cli_command(self) -> str:
name = ""
if self._store is not None and self._store.manifest is not None:
name = self._store.manifest.project_name
- return f"track2data run {name}.t2d.json --workers {self._workers.value()}"
+ project_file = shlex.quote(f"{name}.t2d.json")
+ command = f"track2data run {project_file} --workers {self._workers.value()}"
+ cache_dir = self._store.cache_dir if self._store is not None else None
+ if cache_dir is not None:
+ command += f" --cache-dir {shlex.quote(str(cache_dir))}"
+ return command
def _refresh_cli_box(self) -> None:
self._cli_label.setText(self.cli_command())
diff --git a/ui/store/project_store.py b/ui/store/project_store.py
index cfe5cb03..f4a60540 100644
--- a/ui/store/project_store.py
+++ b/ui/store/project_store.py
@@ -44,7 +44,7 @@
ZoneSet,
)
from track2data.core.progress import CancellationToken, OperationCancelled
-from track2data.readers import find_reader
+from track2data.core.session_identity import same_session_part
from track2data.views.pairing import PairingResult, fish_labels, identity_map, pair_by_regex
from ui.store.session_facts import SessionFacts
from ui.store.task_runner import TaskRunner
@@ -88,36 +88,9 @@ def raise_if_cancelled(self) -> None:
self._check()
-def _selector(ref: SessionRef) -> tuple[tuple[str, str], ...]:
- """The options that pick *which* session in a file (an arena), not how to read it."""
- reader = find_reader(ref.reader) if ref.reader else None
- names = {p.name for p in reader.parameters if p.scope == "session"} if reader else set()
- return tuple(sorted((k, repr(v)) for k, v in ref.reader_options.items() if k in names))
-
-
-def _resolved(path: Path) -> Path:
- try:
- return Path(path).resolve()
- except OSError:
- return Path(path)
-
-
def _is_duplicate(ref: SessionRef, existing: Sequence[SessionRef]) -> bool:
- """Whether *ref* is a session the project already has: the same place, read by the same
- reader (a legacy entry with no saved reader counts as any), picking the same arena.
-
- The same folder under another reader is not a duplicate: another reader could read the same
- files into different numbers, so it is a different session. So is the same folder with
- another panel: it is another part of the video.
- """
- place, selector = _resolved(ref.folder), _selector(ref)
- return any(
- _resolved(other.folder) == place
- and (other.reader is None or other.reader == ref.reader)
- and _selector(other) == selector
- and other.panel == ref.panel
- for other in existing
- )
+ """Whether *ref* is a session the project already has (see ``same_session_part``)."""
+ return any(same_session_part(ref, other) for other in existing)
@dataclass
@@ -822,8 +795,13 @@ def update_sessions(self, sessions: list[SessionRef]) -> None:
if p.top_session_id in kept and p.side_session_id in kept
]
pairs_dropped = len(pairs) != len(self._manifest.view_pairs)
+ # A video located for a removed session must not carry over to a later session that
+ # happens to get the same id (the default id is the folder name).
+ overrides = {
+ sid: path for sid, path in self._manifest.video_overrides.items() if sid in kept
+ }
self._manifest = self._manifest.model_copy(
- update={"sessions": list(sessions), "view_pairs": pairs}
+ update={"sessions": list(sessions), "view_pairs": pairs, "video_overrides": overrides}
)
self.sessionsChanged.emit()
if pairs_dropped:
diff --git a/ui/widgets/panel_preview.py b/ui/widgets/panel_preview.py
index 9d7d7b82..75d9166a 100644
--- a/ui/widgets/panel_preview.py
+++ b/ui/widgets/panel_preview.py
@@ -11,7 +11,7 @@
import numpy as np
from PySide6.QtCore import QRectF, Qt
-from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPainterPath, QPen, QPixmap
+from PySide6.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen, QPixmap
from PySide6.QtWidgets import (
QGraphicsPathItem,
QGraphicsPixmapItem,
@@ -22,9 +22,9 @@
)
from track2data.core.models import PanelRect
-from track2data.readers.video_meta import extract_frame
from ui.widgets.backdrop import load_backdrop_image
from ui.widgets.trajectory_view import ANIMAL_COLORS
+from ui.widgets.video_frame import decode_first_frame
BackdropKind = Literal["image", "video", "tracks", "none"]
@@ -87,7 +87,7 @@ def set_source(
image = load_backdrop_image(background_path, crop)
kind: BackdropKind = "image"
if image is None and crop is None:
- image = self._video_frame(video_path, round(width), round(height))
+ image = decode_first_frame(video_path, round(width), round(height))
kind = "video"
self._kind = "none"
if image is not None:
@@ -113,19 +113,6 @@ def set_rects(self, top: PanelRect | None, side: PanelRect | None) -> None:
self._rects = (top, side)
self._draw_rects()
- @staticmethod
- def _video_frame(video_path: Path | None, w: int, h: int) -> QImage | None:
- if video_path is None or w <= 0 or h <= 0 or not Path(video_path).exists():
- return None
- try:
- data = extract_frame(Path(video_path), 0)
- except Exception:
- return None
- if not data or len(data) != w * h * 3:
- return None
- # copy() so the image owns its pixels and not the bytes object
- return QImage(data, w, h, w * 3, QImage.Format.Format_RGB888).copy()
-
def _draw_tracks(self, tracks: np.ndarray) -> bool:
"""Draw *tracks* as points; False when they are not (n_frames, n_animals>0, >=2)."""
if np.ndim(tracks) != 3:
diff --git a/ui/widgets/video_frame.py b/ui/widgets/video_frame.py
new file mode 100644
index 00000000..b4250781
--- /dev/null
+++ b/ui/widgets/video_frame.py
@@ -0,0 +1,37 @@
+"""The first frame of a session's video as a QImage, for use as a backdrop.
+
+Shared by the panel editor's preview and the zones canvas. Zones are stored in the video's
+declared pixel space, so a frame is only ever returned when the decoder reports exactly that
+size: a different frame would put every vertex in the wrong picture.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PySide6.QtGui import QImage
+
+from track2data.readers.video_meta import extract_frame_rgb
+
+
+def decode_first_frame(video_path: Path | None, width_px: int, height_px: int) -> QImage | None:
+ """The first frame of *video_path*, or None when it cannot be used as a backdrop.
+
+ None when there is no readable video, the declared size is unknown (not positive), the
+ decoder fails, or the decoded frame is not ``width_px`` x ``height_px``. The size is
+ compared as decoded dimensions, not as a byte count: a rotated frame (1080x1920 against a
+ declared 1920x1080) has the same ``w * h * 3`` bytes.
+ """
+ if video_path is None or width_px <= 0 or height_px <= 0 or not Path(video_path).exists():
+ return None
+ try:
+ frame = extract_frame_rgb(Path(video_path), 0)
+ except Exception:
+ return None
+ if frame is None:
+ return None
+ data, width, height = frame
+ if (width, height) != (width_px, height_px) or len(data) != width_px * height_px * 3:
+ return None
+ # copy() so the image owns its pixels and not the bytes object
+ return QImage(data, width_px, height_px, width_px * 3, QImage.Format.Format_RGB888).copy()
diff --git a/ui/widgets/zone_canvas.py b/ui/widgets/zone_canvas.py
index 2c762f28..4335cc23 100644
--- a/ui/widgets/zone_canvas.py
+++ b/ui/widgets/zone_canvas.py
@@ -33,6 +33,7 @@
from track2data.core.models import PanelRect
from ui.widgets.backdrop import load_backdrop_image
+from ui.widgets.video_frame import decode_first_frame
#: Click-to-point tolerance, in image pixels (scene units == image
#: pixels here, since the background pixmap is added at its native
@@ -262,6 +263,7 @@ def load_session(
setup_points,
frame_size: tuple[int, int] | None = None,
crop: PanelRect | None = None,
+ video_path: Path | None = None,
) -> None:
"""Load a new session's backdrop + setup_points, discarding any
prior selection -- called when the Zones screen's session
@@ -273,7 +275,12 @@ def load_session(
not the video.
*crop* is the session's panel: the backdrop is cut to it, since the zones of a panel
- session are relative to the panel."""
+ session are relative to the panel.
+
+ *video_path* is the session's video. When there is no backdrop image, its first frame
+ is drawn instead, but only for a whole-frame session (no *crop*) and only when the
+ decoded frame is exactly *frame_size*: a frame of any other size would put the zones in
+ the wrong pixel space, so the blank scene stays."""
self._gscene.clear()
self._marker_items = {}
self._label_items = {}
@@ -286,6 +293,8 @@ def load_session(
pixmap: QPixmap | None = None
image = load_backdrop_image(background_image_path, crop)
+ if image is None and crop is None and video_path is not None and frame_size is not None:
+ image = decode_first_frame(video_path, frame_size[0], frame_size[1])
if image is not None:
pixmap = QPixmap.fromImage(image)
diff --git a/ui/zones_screen.py b/ui/zones_screen.py
index 5073e094..77729c36 100644
--- a/ui/zones_screen.py
+++ b/ui/zones_screen.py
@@ -486,6 +486,7 @@ def _refresh_canvas(self) -> None:
facts.setup_points,
frame_size=(facts.width_px, facts.height_px),
crop=self._session_panel(session_id),
+ video_path=facts.video_path,
)
def _session_panel(self, session_id: str):