Skip to content

Repository files navigation

LMT QC Pipeline Documentation

This README documents a 5-script pipeline that takes a Live Mouse Tracker (LMT) SQLite output, cleans it, infers a mouse's in-nest/out-of-nest state for frames where the tracker lost detection, resolves ambiguous gaps via a human-in-the-loop binary-search video review, and then draws QC samples to measure the pipeline's accuracy against human judgment.

The pipeline exists because the raw LMT detector is not always able to see the animal. Huddling, nesting material, and running wheel all produce stretches of missing detections. Simply ignoring those gaps would silently underestimate time spent in the nest; naively assuming "last known state" across a long gap would overestimate it in the other direction. Each script in this pipeline exists to resolve that ambiguity a little further, using the cheapest reliable method available before escalating to a more expensive one: static spatial rules first, then a small number of targeted human video reviews, and finally an independent statistical audit of how well the whole process performed.

Find more information about Live Mouse Tracker (LMT) on its website and publication.

Contents

Execution order

  1. 0.Preprocessing.py: clean raw LMT SQLite (deduplicate DETECTION rows).
  2. 1.lmt_gap_fill.py: classify detected frames + logic-fill/flag gaps per animal.
  3. 2.lmt_binary_search.py: human-in-the-loop resolution of ambiguous gaps via video.
  4. 3.lmt_qc_sampler.py: draw random QC samples per pool + extract screenshots.
  5. 4.lmt_qc_validator.py: manual labeling of QC samples + accuracy metrics.

Each script's SQLite output is the next script's SQLite input; video files are re-supplied independently at each step that needs them (scripts 2, 3, and 4) rather than being passed along in the database.

Setup & Running

This repository is a uv project. uv manages the Python interpreter, virtual environment, and dependencies together. You do not need to create a virtualenv or pip install anything by hand.

Install once:

uv sync

This provisions Python 3.12 (pinned in .python-version) and installs the exact dependency versions pinned in uv.lock (numpy, pandas, opencv-python, Pillow) into a local .venv/.

Run any script:

Scripts 0, 1, and 3 are pure command-line tools; every input is a CLI argument/flag. Scripts 2 and 4 take their file/video/folder inputs as CLI arguments too, but still open a GUI window for the interactive part. See the CLI Reference section below for the full argument list for each script, or run any script with --help.

uv run python 0.Preprocessing.py --help

uv run automatically ensures the environment is in sync with uv.lock before launching, so there's no separate "activate the venv" step.

Adding or updating a dependency:

uv add <package>       # add a new dependency
uv lock --upgrade       # refresh pinned versions in uv.lock

Both commands update pyproject.toml/uv.lock together, commit both files afterward.

Note on tkinter: scripts 0, 1, and 3 are pure command-line tools and have no GUI at all, they run fine on a headless/server environment. Scripts 2 and 4 take their setup inputs as CLI arguments but still open a GUI window for their interactive step (binary-search/checkpoint review, and manual QC labeling, respectively), so they still need a graphical display (X11, Wayland, macOS, or Windows) to run. uv's managed Python build already bundles Tcl/Tk, so no separate system python3-tk package install is required for the two scripts that still need it.


CLI Reference

Every argument below is exactly as declared in each script's argparse parser (or, for script 2, _build_arg_parser()). Run any script with --help (or -h) to see this same information from argparse directly.

0.Preprocessing.py

Required:

Argument Type Controls
-i, --input path The LMT Output SQLite to clean.
-o, --output-folder directory Where {input_name}_processed.sqlite is written.

Optional (no value taken, plain flags):

Argument Default Controls
--overwrite off Overwrite {input_name}_processed.sqlite if it already exists in the output folder. Without it, the script aborts rather than replacing a previous run's output.

Example:

uv run python 0.Preprocessing.py -i "/path/to/input/<input_sqlite_file>.sqlite" -o "/path/to/output/<output_directory>"

1.lmt_gap_fill.py

Required: Unlike the retired GUI (which had default values), the CLI intentionally does not have a default animal ID or ROI/buffer coordinates: an unnoticed pre-filled value silently applied to the wrong animal or the wrong nest geometry would corrupt the in-nest time estimate without any indication something was wrong, so the script refuses to run until every one of these is supplied explicitly.

Argument Type Controls
-i, --input path The {name}_processed.sqlite file from 0.Preprocessing.py.
-o, --output-folder directory Where the timestamped lmt_gap_fill_...sqlite result is written.
--animal-id int Which animal's DETECTION rows to process.
--nest-xmin float Nest ROI X minimum.
--nest-xmax float Nest ROI X maximum.
--nest-ymin float Nest ROI Y minimum.
--nest-ymax float Nest ROI Y maximum.
--buffer-xmin float Buffer ROI X minimum.
--buffer-xmax float Buffer ROI X maximum.
--buffer-ymin float Buffer ROI Y minimum.
--buffer-ymax float Buffer ROI Y maximum.

Optional: none, every argument above is required.

Example:

uv run python 1.lmt_gap_fill.py -i "/path/to/input/<*_processed>.sqlite" -o "/path/to/output/<output_directory>" --animal-id <animal_id> --nest-xmin <nest_xmin> --nest-xmax <nest_xmax> --nest-ymin <nest_ymin> --nest-ymax <nest_ymax> --buffer-xmin <buffer_xmin> --buffer-xmax <buffer_xmax> --buffer-ymin <buffer_ymin> --buffer-ymax <buffer_ymax>

2.lmt_binary_search.py

Required:

Argument Type Controls
-i, --input path The lmt_gap_fill_A<animal_id>_<timestamp>.sqlite file from 1.lmt_gap_fill.py.
-v, --videos one or more paths LMT video file(s) (*.mp4) covering the gaps to review.
-o, --output-folder directory Where the resulting SQLite/report is written.

Optional:

Argument Default Controls
--show-nest-roi off Overlay the Nest ROI rectangle (solid outline) on all three review frames (before/QC/after).
--show-buffer-roi off Overlay the Buffer ROI rectangle (dashed outline, to stay visually distinct from the solid Nest ROI) on all three review frames.
--roi-color yellow Outline colour for both the Nest and Buffer ROI overlays (any PIL colour name or hex string, e.g. yellow or #FFFF00).
--roi-thickness 2 Outline thickness in pixels for both overlays.

Neither ROI can be supplied on this script's command line at all. There is no --nest-*/--buffer-* flag here. Both are read exclusively from the input file's ROI_METADATA table (written by 1.lmt_gap_fill.py). If --show-nest-roi and/or --show-buffer-roi is passed and the corresponding ROI isn't available there (e.g. an input file from an older 1.lmt_gap_fill.py), the script errors out at startup, re-run 1.lmt_gap_fill.py with the current version rather than trying to supply the ROI here.

Thresholds, review interval, etc. remain hardcoded module constants, unaffected by any of the above.

Example (no overlay):

uv run python 2.lmt_binary_search.py -i "/path/to/input/lmt_gap_fill_A<animal_id>_<date>.sqlite" -v /path/to/videos/*.mp4 -o "/path/to/output/<output_directory>"

Example (both overlays on):

uv run python 2.lmt_binary_search.py -i "/path/to/input/lmt_gap_fill_A<animal_id>_<date>.sqlite" -v /path/to/videos/*.mp4 -o "/path/to/output/<output_directory>" --show-nest-roi --show-buffer-roi

Example (overlays on, with a custom shared colour/thickness):

uv run python 2.lmt_binary_search.py -i "/path/to/input/lmt_gap_fill_A<animal_id>_<date>.sqlite" -v /path/to/videos/*.mp4 -o "/path/to/output/<output_directory>" --show-nest-roi --show-buffer-roi --roi-color "#00FFFF" --roi-thickness 4

Note: If LMT is configured to request raw videos without overlays, both raw and overlay .mp4 files may be created in the same folder. Using *.mp4 will select all .mp4 files, so make sure the video pattern/path matches the files you intend to use.

3.lmt_qc_sampler.py

Required:

Argument Type Controls
-i, --input path The lmt_binary_search_A<animal_id>_<timestamp>.sqlite file from 2.lmt_binary_search.py.
-v, --videos one or more paths LMT video file(s) (*.mp4) to extract sample screenshots from.
-o, --output-folder directory Where per-pool results are written.

Optional:

Argument Default Controls
-n, --samples 100 Number of samples to draw, applied independently to each selected pool.
--pools all three (DETECTED BINARY_SEARCH LOGIC) Which QC pool(s) to sample from; pass one or more of DETECTED, BINARY_SEARCH, LOGIC.
--overwrite off Overwrite a pool's output folder if it already contains files from an earlier run today. Without it, that pool aborts.

Example:

uv run python 3.lmt_qc_sampler.py -i "/path/to/input/lmt_binary_search_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite" -v /path/to/videos/*.mp4 -o "/path/to/output/<output_directory>" -n 150 --pools DETECTED LOGIC

Note: If LMT is configured to request raw videos without overlays, both raw and overlay .mp4 files may be created in the same folder. Using *.mp4 will select all .mp4 files, so make sure the video pattern/path matches the files you intend to use.

4.lmt_qc_validator.py

Required:

Argument Type Controls
-i, --input path The lmt_qc_sampler_<qc_mode>_A<animal_id>_<timestamp>.sqlite file from 3.lmt_qc_sampler.py.
-s, --screenshot-folder directory The Screenshots/ folder produced alongside that same file.

Optional:

Argument Default Controls
-v, --videos none (empty) LMT video file(s) (*.mp4); when supplied, enables the three-panel before/QC-frame/after view for BINARY_SEARCH/LOGIC/legacy ASSUMED-mode samples. Not needed for DETECTED-mode samples, which show only the pre-extracted screenshot.
--show-nest-roi off Overlay the Nest ROI rectangle (solid outline) on every displayed frame: the three-panel view and both single-panel fallbacks (DETECTED mode, and ASSUMED-type mode with no videos/boundaries).
--show-buffer-roi off Overlay the Buffer ROI rectangle (dashed outline, to stay visually distinct from the solid Nest ROI) on every displayed frame.
--roi-color yellow Outline colour for both the Nest and Buffer ROI overlays (any PIL colour name or hex string, e.g. yellow or #FFFF00).
--roi-thickness 2 Outline thickness in pixels for both overlays.

Neither ROI can be supplied on this script's command line at all. (Git Issue #22 follow-up, consistent with 2.lmt_binary_search.py). There is no --nest-*/--buffer-* flag here. Both are read exclusively from the input file's ROI_METADATA table, which 2.lmt_binary_search.py and 3.lmt_qc_sampler.py each forward into their own output if their own input had it. If --show-nest-roi and/or --show-buffer-roi is passed and the corresponding ROI isn't available there (e.g. any file earlier in this run's 1234 chain predates ROI_METADATA), the script errors out at startup with a message naming exactly which overlay(s) are missing a ROI.

Example (no overlay):

uv run python 4.lmt_qc_validator.py -i "/path/to/input/lmt_qc_sampler_<qc_mode>_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite" -s "/path/to/output/<screenshots_directory>" -v /path/to/videos/*.mp4

Example (both overlays on):

uv run python 4.lmt_qc_validator.py -i "/path/to/input/lmt_qc_sampler_<qc_mode>_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite" -s "/path/to/output/<screenshots_directory>" -v /path/to/videos/*.mp4 --show-nest-roi --show-buffer-roi

Note: If LMT is configured to request raw videos without overlays, both raw and overlay .mp4 files may be created in the same folder. Using *.mp4 will select all .mp4 files, so make sure the video pattern/path matches the files you intend to use.


Script: 0.Preprocessing.py

Overview

This script exists to give every downstream script a single, well-defined notion of "a valid detection row" for a given frame and animal. LMT output SQLite's DETECTION table can contain duplicate or conflicting rows for the same (FRAMENUMBER, ANIMALID) pair; every later script in this pipeline assumes that pair is unique, so this cleanup has to happen first, once, rather than being re-implemented as a filter in every other script.

This script no longer removes rows based on FRONT_X/FRONT_Y/ FRONT_Z/BACK_X/BACK_Y/BACK_Z being -1. It previously did, on the assumption that a -1 FRONT/BACK coordinate meant the row was unusable. QC testing across several real SQLite databases showed that assumption doesn't hold: a row can have FRONT_*/BACK_* all -1 while still carrying an accurate, usable MASS_X/MASS_Y position (see Git Issue #26), the coordinate pair this pipeline actually relies on throughout. Those rows are now kept unchanged; this script doesn't inspect the FRONT_*/ BACK_* columns at all.

Instead, the script creates a cleaned copy of a raw LMT Output SQLite database (it never modifies the original file) and deduplicates the DETECTION table on (FRAMENUMBER, ANIMALID), the identity every downstream script assumes is unique for a single animal's timeline, using two rules:

  • Case A - completely identical rows. If two or more rows are identical across every column (excluding a surrogate/auto-increment primary key, if the table has one), the first occurrence (by original row order) is kept, and the later identical rows are deleted.
  • Case B - conflicting rows. If two or more rows share the same (FRAMENUMBER, ANIMALID) but disagree on some other column (e.g. two different MASS_X/MASS_Y readings for the same frame and animal), there is no principled way to pick a "correct" one, so every row in that group is deleted, including what would have been the first occurrence.

This matters beyond just data cleanliness: 1.lmt_gap_fill.py's gap-detection logic assumes FRAMENUMBER strictly increases for a given animal (see Git Issue #5). A duplicate or out-of-order FRAMENUMBER silently corrupts that script's gap sizing and the resulting in-nest time estimate, without raising any error, unless this deduplication has already run.

After deletion, it runs VACUUM to reclaim the disk space freed by the deleted rows (SQLite does not shrink a database file automatically after a DELETE; the file only shrinks once VACUUM rewrites it).

Inputs

Input Type Purpose
LMT Output SQLite SQLite database, table DETECTION Raw LMT tracking output to be cleaned.
Output folder Directory Where the cleaned copy is written.

DETECTION columns referenced:

Column Role
FRAMENUMBER, ANIMALID The identity pair deduplication groups on. Both are required to be present; the script errors out clearly if either is missing.
Every other column (MASS_X, MASS_Y, FRONT_*, BACK_*, and any others the table happens to have) Compared for row equality to distinguish Case A (identical) from Case B (conflicting), but never inspected for a specific value. This script no longer makes any deletion decision based on what a coordinate is, only on whether two rows for the same frame/animal match. A surrogate/auto-increment primary key column, if the table has one, is excluded from this comparison.

All other tables in the source database are untouched and are not inspected by this script.

Outputs

Output Type Purpose
{original_name}_processed.sqlite SQLite database Deduplicated input for 1.lmt_gap_fill.py.

Processing Steps

  1. Select input and output via CLI arguments (-i/--input, -o/--output-folder).
  2. Overwrite check if {original_name}_processed.sqlite already exists in the output folder, the script aborts with a clear error unless --overwrite was passed, rather than silently replacing a previous run's output.
  3. Copy the file with shutil.copy2, a single filesystem-level copy, rather than reading and rewriting rows in Python. This is both far faster and guarantees every table/column the script doesn't know about is preserved byte-for-byte.
  4. Verify the DETECTION table exists in the copy before running any query against it, so a non-LMT or corrupted file produces a clear error instead of a raw SQLite exception. Also verifies FRAMENUMBER and ANIMALID are both present as columns.
  5. Load every DETECTION row, along with its rowid (SQLite's implicit, insertion-order row identifier), so "first occurrence" in Case A can be determined deterministically from the original file's row order rather than an arbitrary in-memory order.
  6. Group by (FRAMENUMBER, ANIMALID) and, within each group with more than one row, check whether every row is identical across all non-primary-key columns. A fully-identical group is Case A (keep the lowest-rowid row, delete the rest); a group with any disagreement is Case B (delete every row in the group). Groups of size one are left untouched.
  7. Report counts for each case (groups affected and rows removed), plus up to 10 example (FRAMENUMBER, ANIMALID) pairs for Case B so a user can investigate the conflicting source data if needed, and exit early (no-op) if nothing needs deleting.
  8. Delete in bulk via a temporary table of row IDs to remove plus a single DELETE ... WHERE rowid IN (...) statement, a set-based SQL operation rather than a per-row Python loop, which matters when a session has hundreds of thousands of frames.
  9. VACUUM to physically reclaim the freed space, via VACUUM INTO a sibling temp file followed by an atomic rename over the output file, rather than an in-place VACUUM (see Key Design Decisions below for why).
  10. Report a timing summary (copy / delete / vacuum durations) so a user running this against a very large database understands where the time went.

Key Design Decisions & Assumptions

  • Copy-then-modify, never mutate the source. The original LMT database is treated as an immutable, re-runnable source of truth; every later stage operates on derived files, so a mistake anywhere downstream never requires re-exporting from LMT.
  • Deduplicate on identity (FRAMENUMBER + ANIMALID), not on any coordinate value. Earlier versions of this script deleted rows based on FRONT_X = -1, treating a specific coordinate value as an invalidity sentinel. That conflated "this row's FRONT/BACK tracking failed" with "this row is unusable," which QC testing showed to be false: MASS_X/ MASS_Y, the values this pipeline is actually built on, can still be accurate on such a row. Whether two rows for the same frame and animal agree is a much safer signal than what either row's coordinates happen to be. This was validated against 120 rows sampled across 4 SQLite databases where FRONT_*/BACK_* were all -1 but MASS_X/ MASS_Y were valid: 100% of the sampled MASS_X/MASS_Y positions matched the animal's actual position in Live Data Player/video. The current interpretation is that RFID-associated tracking can supply MASS_X/ MASS_Y even when FRONT/BACK coordinates aren't available, though this is an observation about the sampled data, not something the code itself verifies row-by-row. See Git Issue #26 for the original analysis.
  • Rows missing FRAMENUMBER and/or ANIMALID (including anonymous detections (ANIMALID IS NULL)) are left untouched, not deduplicated. ANIMALID IS NULL means LMT detected a mouse but could not identify it; this is different from "no detection" (see Git Issue #28). Grouping these rows into the dedup logic anyway (e.g. via groupby(..., dropna=False)) would treat "missing identity" as itself a shared identity value, silently lumping together unrelated anonymous detections and risking their mass-deletion as a false "conflicting group." This script only ever deletes rows it can positively identify as exact duplicates or as conflicting with another row for the same identity, and a row with no identity at all can be neither, so these rows pass through unchanged for whatever downstream stage might use them (Git Issues #28 and #30 notes anonymous detections may be useful for future QC/human review/visualization work, even though the current pipeline doesn't yet act on them beyond preserving them here).
  • No arbitrary tie-breaking on conflicting data. When two rows disagree about the same (FRAMENUMBER, ANIMALID), there's no information in this table that says which one is correct, so silently keeping one (e.g. "first wins") would quietly inject wrong data into the timeline. Deleting the whole group instead turns that frame into an ordinary gap, which downstream scripts already have a principled way to handle.
  • All database work is wrapped in try/except/finally so the SQLite connection is always closed, even if a step fails partway through, and so a failure produces a readable error message on stderr (with a non-zero exit code) instead of a raw console traceback.
  • VACUUM INTO a sibling temp file plus an atomic swap, not an in-place VACUUM. An in-place VACUUM builds its rebuild scratch file in SQLite's default temp location (typically the system temp directory), which can be on a much smaller partition than the output folder and can fail with "database or disk is full" on a large database even when the output location has plenty of space. VACUUM INTO writes the compacted copy directly alongside the output file instead, then os.replace swaps it in; if that step fails, the already-committed, uncompacted output from the DELETE step is left in place rather than losing the run's result.

Open Source Notes

  • External dependencies: pandas (used for the deduplication group-by logic).
  • Standard library: argparse, os, shutil, sqlite3, sys, time.
  • Configuration files / environment variables: none.
  • Expected directory structure: none required; input file and output folder are given as CLI arguments.
  • Platform assumptions: none, fully headless, runs on any environment with Python (no display required). VACUUM runs synchronously on the main thread, so the process may appear unresponsive during this step on very large databases.

Script: 1.lmt_gap_fill.py

Overview

This script exists to turn a sparse, animal-specific stream of detected frames into a complete, per-frame timeline, and to do as much of that work as possible without requiring a human to look at any video. For every animal, most of the timeline is unambiguous: a frame is either directly observed by LMT, or it falls inside a short gap where simple geometry makes the answer obvious. This script resolves everything it safely can using two static spatial checks, and explicitly flags the remainder as unresolved so that the more expensive, human-driven process in 2.lmt_binary_search.py only has to look at the frames that genuinely need it.

This script does not perform any interactive or binary search. It applies a fixed, two-part geometric rule to each gap's two boundary frames and immediately commits to one of two outcomes: confidently fill the gap, or mark it uncertain. The algorithm that actually resolves an uncertain gap by interactively narrowing down where the animal crossed a boundary (including how it distinguishes an entry from an exit and how it converges on an exact transition frame) is implemented entirely in 2.lmt_binary_search.py; see that script's Processing Steps section below for the full explanation.

Inputs

Input Type Purpose
{original_name}_processed.sqlite (script 0 output) SQLite database, table DETECTION Source of per-frame animal positions.
Animal ID Integer Which animal's DETECTION rows to process.
Nest ROI (xmin, xmax, ymin, ymax) Floats Bounding box defining "inside the nest."
Buffer ROI (xmin, xmax, ymin, ymax) Floats A looser bounding box, expected to fully contain the nest ROI, defining "close enough to the nest that leaving detection range is plausibly benign."
Output folder Directory Where the result database is written.

DETECTION columns used: FRAMENUMBER, MASS_X, MASS_Y (animal centroid position), ANIMALID (filter, passed as a bound SQL parameter).

Outputs

Output Type Purpose
lmt_gap_fill_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite SQLite database, tables GAP_FILL_ANALYSIS + ROI_METADATA Complete per-frame in-nest classification for one animal, including unresolved gaps flagged for script 2, plus (Git Issue #22) the Nest/Buffer ROI this run used.

GAP_FILL_ANALYSIS columns:

Column Meaning
FRAMENUMBER Frame index.
IN_NEST 1 = in nest, 0 = out of nest (both only for DETECTED rows); for ASSUMED rows, 1 = confidently logic-filled in-nest, -1 = uncertain, deferred to script 2. ASSUMED rows never receive a plain 0 from this script.
ASSUMPTION_TYPE "DETECTED" (an actual LMT observation) or "ASSUMED" (a filled-in gap frame).
GAP_START_FRAME For ASSUMED rows, the last detected frame before the gap; None for DETECTED rows.
GAP_END_FRAME For ASSUMED rows, the first detected frame after the gap; None for DETECTED rows.
ANIMALID The animal ID this run was filtered to (the --animal-id argument), on every row.

ROI_METADATA (Git Issue #22): one row, written alongside GAP_FILL_ANALYSIS in the same output file, recording exactly the ROI values this run was given: ANIMALID, NEST_XMIN, NEST_XMAX, NEST_YMIN, NEST_YMAX, BUFFER_XMIN, BUFFER_XMAX, BUFFER_YMIN, BUFFER_YMAX. This exists so 2.lmt_binary_search.py can automatically reuse the same Nest ROI for its optional on-frame overlay (and both ROIs for its summary report) without the user retyping them (see that script's Do NOT Modify notes below for why there's no CLI override for the Nest ROI specifically).

Processing Steps

  1. Load one animal's detections, ordered by FRAMENUMBER, using a parameterized query (WHERE ANIMALID = ?).
  2. Classify every detected frame directly, using the nest ROI: a detected frame's IN_NEST value is simply whether its (MASS_X, MASS_Y) position falls strictly inside the nest bounding box. This is the ground truth the rest of the pipeline is built on.
  3. Walk every consecutive pair of detected frames and compute the frame distance between them. A distance of 1 means no gap. A distance greater than 1 means one or more frames went undetected in between a gap and every missing FRAMENUMBER in that range becomes an ASSUMED row. A distance of 0 or less (a duplicate or out-of-order FRAMENUMBER for this animal) is a defensive validation failure (Git Issue #5) rather than a silently-accepted "no gap" (see Key Design Decisions below).
  4. Decide each gap's fate using only its two endpoints (this is the core heuristic, and the reason a human is not required for most gaps):
    • Test whether the animal was inside the nest ROI at the last frame before the gap.
    • Test whether the animal was inside the wider buffer ROI at the first frame after the gap.
    • If both are true, every frame in the gap is filled with IN_NEST = 1. The reasoning: the animal was already home when tracking was lost, and was still within a generous margin of home the moment tracking resumed. The most plausible explanation is that it simply held still in or near the nest while briefly occluded (e.g. by nesting material), rather than having left and returned undetected.
    • If either test fails, the gap is left as IN_NEST = -1. The script deliberately does not guess in this case, because it has no visual information about what happened during the gap and a wrong guess here would silently corrupt the in-nest time estimate.
  5. Reassemble the full timeline by combining the detected rows and the generated assumed rows and sorting by FRAMENUMBER, then write the result to a new, timestamped SQLite file, along with the ROI_METADATA row for this run (Git Issue #22).

Key Design Decisions & Assumptions

  • Defensive uniqueness check, not a repeat of 0.Preprocessing.py's deduplication (Issue #5). This script assumes FRAMENUMBER strictly increases for the animal it's processing; a duplicate or out-of-order value would otherwise be silently treated as "no gap" (a <= 0 distance between consecutive frames), corrupting gap sizing and the in-nest time estimate without any error. 0.Preprocessing.py's dedup on (FRAMENUMBER, ANIMALID) is expected to make this invariant hold by the time this script runs, so the check here should never actually trigger in the normal pipeline order. It exists to fail loudly, rather than corrupt results silently, if this script is ever run against data that skipped that step.
  • Anonymous detections (ANIMALID IS NULL) never enter a single animal's timeline (Issue #28). The source query (WHERE ANIMALID = ?) binds a specific integer Animal ID; under standard SQL NULL semantics this never matches a row where ANIMALID is NULL, so anonymous detections are excluded from the result set before any gap-fill logic runs. ANIMALID IS NULL means "a mouse was detected but LMT could not identify it," not "no detection," treating it as identified-and-belonging-to-this-animal would risk attributing an anonymous detection to the wrong animal (a false positive) with no way to verify the assignment. For a single animal's timeline, an anonymous detection is therefore currently indistinguishable from "no detection at all": both leave that frame to be resolved as a gap by the logic below. This is a deliberate scope decision. Git Issues #28 and #30 notes anonymous detections may still be valuable for future QC, human review, or visualization work that isn't tied to a specific animal's timeline, just not for this script's purpose.
  • Two independent, asymmetric ROI tests, not one. The buffer ROI is intentionally looser than the nest ROI. Requiring the exit point to only be within the wider buffer (rather than the strict nest box) tolerates ordinary positional noise right at the edge of tracking loss, while still requiring the entry point (start of the gap) to be strictly within the nest.
  • The algorithm only ever looks at gap endpoints, never intermediate positions (there are none, that's what makes it a gap). This makes the rule fast and fully vectorizable, but it also means it is fundamentally unable to detect an entry-and-exit (or exit-and-entry) that both happen inside the same gap; such cases are exactly what get pushed downstream as -1.
  • ASSUMED rows are only ever 1 or -1, never 0. This convention is load-bearing: 2.lmt_binary_search.py selects its work queue with df[df["IN_NEST"] == -1], and would silently skip frames if this script ever wrote a plain 0 for an assumed frame.

Do NOT Modify

  • ROI membership tests use strict inequality (<, not <=); changing this changes which frames are considered "at the boundary" and shifts results in a way no downstream script expects.
  • IN_NEST = -1 must remain the exact sentinel for "uncertain", both 2.lmt_binary_search.py and 4.lmt_qc_validator.py filter on this exact value.
  • Table name GAP_FILL_ANALYSIS and all six output columns (FRAMENUMBER, IN_NEST, ASSUMPTION_TYPE, GAP_START_FRAME, GAP_END_FRAME, ANIMALID) must keep these exact names: 2.lmt_binary_search.py loads the whole table with SELECT * (no column names hardcoded in the query itself), but then accesses each of these columns by name throughout, so renaming or dropping any of them breaks that script.
  • GAP_START_FRAME / GAP_END_FRAME must remain "last detected frame before" / "first detected frame after". Script 2 groups and classifies gaps using this exact definition.
  • Table name ROI_METADATA and its eight column names (ANIMALID, NEST_XMIN/NEST_XMAX/NEST_YMIN/NEST_YMAX, BUFFER_XMIN/BUFFER_XMAX/BUFFER_YMIN/BUFFER_YMAX) must keep these exact names (Git Issue #22): 2.lmt_binary_search.py reads this table by name and these columns by name to auto-load the Nest/Buffer ROI; renaming or dropping any of them breaks that auto-load (silently falling back to "no ROI available" rather than erroring, since an absent table and a renamed one look the same to that script).

Open Source Notes

  • External dependencies: pandas, numpy (used for the vectorized gap-fill computation).
  • Standard library: argparse, os, sqlite3, sys, datetime.
  • Configuration files / environment variables: none; animal ID and ROI bounds are CLI arguments (--animal-id, --nest-*, --buffer-*), all required with no defaults (see the CLI Reference section above). The same --nest-*/--buffer-* values are also persisted into this run's output SQLite (ROI_METADATA table, Git Issue #22) for 2.lmt_binary_search.py to read back automatically.
  • Expected directory structure: none required beyond a writable output folder.
  • Platform assumptions: none, fully headless, no GUI toolkit is used.

Script: 2.lmt_binary_search.py

Overview

This script exists to resolve exactly the frames 1.lmt_gap_fill.py could not: gaps where the animal's state genuinely changed (or might have) and there is no way to know when without looking at video. Rather than asking a human to scrub through every second of every ambiguous gap, it uses a binary search over frame numbers to find the transition point with a small, logarithmic number of targeted clicks, then writes the fully resolved timeline back out for the rest of the pipeline.

Loads the 1.lmt_gap_fill.py output and isolates every ASSUMED frame still marked IN_NEST = -1. Groups them into gaps, classifies each gap's boundary type, and skips the gaps that cannot or need not be searched. Remaining gaps are queued for an interactive, GUI-driven review: the reviewer is shown a three-panel view (last detected frame before the gap, a candidate frame partway through the gap, and the first detected frame after the gap) and answers IN NEST, OUT OF NEST, or Skip — Cannot Judge. IN NEST/OUT OF NEST recursively narrow the segment until the transition frame is pinned down; Skip abandons the current segment as unresolved instead (see step 5/6 below). Once every gap has been processed, it writes the final per-frame classification (with FILL_SOURCE) plus a detailed plain-text summary report with internal integrity checks.

Two smaller, related pieces of behavior live in this script too:

  • CLI-input logging (Git Issue #21). The summary report records the exact source database, video file(s), and output folder this run was given, so a manual mistake (wrong videos, wrong output folder, etc.) is traceable after the fact from the report alone.
  • Nest/Buffer ROI overlay (Git Issue #22, + follow-up). With --show-nest-roi and/or --show-buffer-roi, the corresponding ROI rectangle is drawn on all three review frames: Nest ROI solid, Buffer ROI dashed, so the two stay visually distinguishable while sharing the same colour/thickness. Both ROIs are read exclusively from the input file's ROI_METADATA table (written by 1.lmt_gap_fill.py), there is no way to supply either one on this script's own command line, so neither overlay can ever silently drift from the ROI 1.lmt_gap_fill.py (and hence GAP_FILL_ANALYSIS's IN_NEST values) actually used. If --show-nest-roi and/or --show-buffer-roi is passed and the corresponding ROI isn't available there (e.g. the input predates the current 1.lmt_gap_fill.py), the script errors out at startup rather than silently skipping the overlay or falling back to some other value.

Inputs

Input Type Purpose
lmt_gap_fill_A<animal_id>_<date>.sqlite (script 1 output) SQLite database, tables GAP_FILL_ANALYSIS + ROI_METADATA Per-frame classification with unresolved (IN_NEST = -1) gaps to review, plus (if present) the Nest/Buffer ROI that produced it.
LMT video files (*.mp4) Video Source frames for the reviewer to visually classify.
Output folder Directory Where results are written.

Video filenames must contain a t<digits> segment immediately before the file extension (e.g. ..._t12345.mp4), giving the video's starting global frame number. Videos are assumed to run at DB_FPS / FRAME_CONVERSION (default 30 / 2 = 15 fps); a video whose actual frame rate deviates from this by more than a small tolerance is flagged to the user, and a video whose filename cannot be parsed is excluded and reported rather than silently dropped.

Outputs

Output Type Purpose
lmt_binary_search_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite (or lmt_binary_search_Aunknown_... if the source lacked ANIMALID) SQLite database, tables GAP_FILL_ANALYSIS + ROI_METADATA Final per-frame classification with fill-method bookkeeping, for QC sampling in script 3, plus the Nest/Buffer ROI this run read, propagated forward unchanged so 3.lmt_qc_sampler.py and eventually 4.lmt_qc_validator.py can auto-load it too. No-ops (table omitted) if this run's own input had no ROI_METADATA.
LMT_Summary_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.txt Text report Audit of detection counts, gap types, binary-search results, internal balance/integrity checks, the CLI inputs this run used (Git Issue #21), and the Nest/Buffer ROI values and their source: "auto-loaded from 1.lmt_gap_fill.py output" or "not provided" (Git Issue #22; there is no "CLI" source anymore. See Do NOT Modify below).
_binsearch_tmp/ Temp image cache Scratch PNGs extracted during review; deleted on successful completion or if the window is closed early.

GAP_FILL_ANALYSIS columns (extends script 1's schema):

Column Meaning
FRAMENUMBER, ASSUMPTION_TYPE, GAP_START_FRAME, GAP_END_FRAME, ANIMALID Carried over from script 1.
IN_NEST Final classification (1, 0, or -1 for a frame that was routed to the interactive reviewer but explicitly marked "cannot judge," or that fell in a gap type/duration this script does not review).
FILL_SOURCE "DETECTED", "LOGIC" (script 1's ROI heuristic decided the value; this column itself is written by script 2, not script 1), "BINARY_SEARCH" (routed to the interactive reviewer here, regardless of whether it resolved to 0/1 or was explicitly skipped and left -1), or "UNKNOWN" (never routed to the reviewer at all: below the duration threshold, or a type-11 gap).

This script no longer writes a separate BINARY_SEARCH integer column. FILL_SOURCE == "BINARY_SEARCH" alone identifies every frame routed to the interactive reviewer; a BINARY_SEARCH column may still be present and readable on older files produced before this change (see script 3's "Detect the source schema" step and lmt_common.py's compute_qc_pool_mask()).

Processing Steps

1. Load and group unresolved gaps. Every ASSUMED row with IN_NEST = -1 is pulled from script 1's output and grouped by (GAP_START_FRAME, GAP_END_FRAME) into distinct gaps.

2. Classify each gap's boundary type. Using the IN_NEST state of the detected frame immediately before the gap and the detected frame immediately after it, every gap is labeled with one of four types:

  • 00 (out → out): no directional information is available from the endpoints alone, the animal could have stayed out the whole time, or briefly entered and left again, and there is no way to tell from the boundary states, so binary search cannot be used (there's no known transition to bisect toward). Above MIN_GAP_DURATION_FOR_BINARY_SEARCH, these gaps are instead reviewed via left-to-right checkpoint sampling (see step 4) rather than left unresolved; at or below the threshold, they're still skipped (left -1), same as before.
  • 11 (in → in): script 1's fill rule requires the frame before the gap to be strictly inside the nest ROI and the frame after it to be inside the (wider) buffer ROI, and the buffer ROI is validated to fully contain the nest ROI. A frame strictly inside the nest ROI is therefore always also inside the buffer ROI, so this fill condition is always satisfied for a type-11 gap's endpoints. Script 1 always resolves these to IN_NEST = 1, and a -1 frame in a type-11 gap should never reach this script. The check here is a defensive one (e.g. against a hand-edited or otherwise inconsistent input file, not a case this pipeline is expected to produce): if it's ever hit, the gap is skipped and its frames are counted separately in the summary report, rather than silently miscounted or crashing.
  • 01 (out → in) and 10 (in → out): these are the gaps that matter, the animal's state is known to differ between the two endpoints, so exactly one transition occurred somewhere inside the gap. These are the only gap types eligible for binary search. Note that script 1's fill rule only requires the after frame to be inside the looser buffer ROI, not the strict nest ROI, so some 10-boundary gaps (in-nest before, but only buffer-adjacent (not strictly in-nest) after) are already resolved by script 1 and never reach this script at all; the 10 gaps seen and reviewed here are only the ones where that buffer test failed. (Type-00 gaps are eligible for a different review mechanism, see step 4)

3. Filter by duration. For 01,10, and 00 gaps, any at or below MIN_GAP_DURATION_FOR_BINARY_SEARCH (default 30 seconds) are left -1 rather than queued for review, a gap this short contributes little to the overall time-in-nest estimate relative to the reviewer time it would cost to resolve precisely.

4. Checkpoint-review the remaining 00 gaps. Since there's no known transition to bisect toward, type-00 gaps aren't handled by binary search from the start. Instead, above-threshold 00 gaps are sampled left-to-right at TYPE00_REVIEW_INTERVAL_SECONDS (default 60 seconds): starting from the gap's left edge, the reviewer is shown a frame every interval and asked whether the animal is in the nest. The final checkpoint in the precomputed sequence always lands exactly on the gap's right edge regardless of interval alignment, so that edge is always explicitly reviewed rather than extrapolated. A gap no longer than one interval gets a single checkpoint covering the whole gap.

Each checkpoint answer is handled one of two ways:

  • OUT OF NEST fills backward from the previous checkpoint (or the gap's   start, for the first one) with 0, and checkpoint sampling continues to   the next checkpoint in the precomputed sequence.
  • IN NEST - the first checkpoint answered this way stops checkpoint   sampling for the gap immediately: any remaining precomputed checkpoints   for this gap are discarded, the sampled segment up to and including this   checkpoint is filled 1, and the rest of the gap (from just after this   checkpoint through the gap's right edge, which is already known   out-of-nest) is handed off to a 10 (in-nest → out-of-nest) binary   search instead of continuing to sample every remaining interval. This   hand-off is possible because the checkpoint that was just answered IN   is now a known left boundary, turning the remainder of the gap into an   ordinary 10 sub-problem.

Unlike a 01/10 binary search, the checkpoint sequence itself is flat and precomputed up front (no recursive subdivision), but on an IN answer, the gap's remaining portion switches to the same recursive binary-search mechanism used for 01/10 gaps (see step 5).

5. Binary search the remaining gaps. This is the core algorithm, and it relies on one key assumption: within a 01 or 10 gap, the animal's in-nest state is monotonic, it changes exactly once, at some unknown frame, from the state at the gap's start to the (different) state at the gap's end. This turns "find the transition frame" into the same problem as searching a sorted array for the boundary between two runs of different values, which is exactly what binary search is designed for: each answer the reviewer gives eliminates roughly half of the remaining candidate frames, so a gap spanning thousands of frames typically resolves in well under twenty clicks rather than requiring the reviewer to scrub through it frame by frame.

The reviewer answers each shown frame with one of three controls: IN NEST (A), OUT OF NEST (D), or Skip — Cannot Judge (W) (see "Convergence and termination" below for what Skip does). Undo () and Redo () step back and forward through the answer history.

Concretely, for a segment [seg_start, seg_end] still being searched, the reviewer is shown the frame at the midpoint and asked whether the animal is currently in the nest. What that answer implies and which half of the segment gets filled immediately versus searched further depends on which direction the gap runs:

  • 10 gap (in → out of nest). The segment starts known in-nest and ends known out-of-nest. If the reviewer answers IN NEST, the exit has not happened yet by the midpoint, so every frame from the segment start through the midpoint is safely filled IN_NEST = 1, and the search continues on the right half (midpoint + 1 through segment end) to keep looking for the exit. If the reviewer answers OUT OF NEST, the exit already happened at or before the midpoint, so the search continues on the left half (segment start through midpoint − 1); once there is no room left to search further left, every remaining frame from the midpoint through the segment end is filled IN_NEST = 0.
  • 01 gap (out of nest → in). The same logic applies mirrored: an IN NEST answer means the entry has already happened by the midpoint, so the right half (midpoint through segment end) is filled 1 immediately, and the search continues on the left half looking for exactly when entry occurred. An OUT OF NEST answer means entry hasn't happened yet, so the search continues on the right half, and once no room remains to search further, everything up through the midpoint is filled 0.

In both directions the reviewer is answering the same underlying question, "has the transition occurred by this frame?," but which half is resolved immediately and which half continues to be searched is flipped, because which endpoint state is "known" differs between an entry and an exit.

6. Convergence and termination. Recursion on a gap's subtasks terminates in one of three ways:

  • The segment being searched shrinks to zero width (seg_start > seg_end), at which point the two already-filled halves fully account for every frame originally in the gap, every subtask either fills a contiguous block outright or hands off exactly the untouched remainder to a new subtask, so no frame is ever double-counted or dropped.
  • As a practical shortcut, once a candidate 01/10 segment's duration   drops to or below FILL_ENTIRE_SEGMENT_IF_DURATION_LESS_THAN_IN_MINUTES   (default 1 minute: despite the name, the actual comparison is inclusive:   exactly 1 minute also qualifies), the entire remaining segment is filled   from a single answer instead of continuing to subdivide down to   individual frames, an OUT OF NEST answer fills the whole segment 0,   an IN NEST answer fills it 1, symmetrically. Sub-minute precision on   exactly which frame a transition occurred is not meaningful for this   pipeline's purposes, so this trades a small amount of possible   imprecision for a large reduction in reviewer clicks.
  • This shortcut is deliberately skipped for type-00 checkpoints,   regardless of segment duration. TYPE00_REVIEW_INTERVAL_SECONDS   (default 60s) is the same length as this shortcut's default threshold   (1 minute), so most type-00 checkpoint segments would otherwise land   exactly on this boundary and short-circuit here, which would silently   skip the type-10 hand-off on an IN answer (step 4), since this shortcut   only fills and advances and never hands off. Type-00 checkpoints always   go through their own answer-handling path instead (step 4), which fills   identically to this shortcut on an OUT answer but adds the hand-off on   an IN answer, independent of segment duration.
  • The reviewer can also end a segment early with "Skip — Cannot Judge (W)." Unlike an IN NEST/OUT OF NEST answer, Skip does not narrow the search: it marks every frame in the current segment -1 and abandons that segment entirely, moving on to the next task. It's the control to use when the frame is genuinely unjudgeable rather than guessed at. Skipped frames participate in Undo/Redo exactly like an IN NEST/OUT OF NEST answer. In the summary report, these frames are counted separately from threshold-skipped frames (gaps that never reached a reviewer at all).

7. Determine the final gap classification. Once every subtask for every gap has been answered, all resulting frame decisions are merged with the unchanged DETECTED frame states into the authoritative final IN_NEST value for every frame, along with FILL_SOURCE bookkeeping. This merge is a mix of vectorized NumPy boolean-mask assignment (for the bulk of the work: splitting already-valid DETECTED frames from the ones needing a decision) and a few small, per-frame Python lookups (checking skipped_frames/searchable_frames set membership and reading each frame's answer out of the decisions dict). Those lookups are against plain Python sets/dicts, not numpy-friendly structures, so they aren't vectorized, but they only run over the ASSUMED frames actually needing resolution, not the full per-frame dataset.

8. Write outputs and report. The final table is written to a new SQLite file, and a detailed summary report is generated with multiple internal balance checks (e.g. that every accounted-for frame category sums back to the original total), if any check fails, an IntegrityError is raised rather than silently emitting a report with unexplained inconsistencies.

Key Design Decisions & Assumptions

  • Binary search is appropriate here specifically because 01/10 gaps are assumed to contain exactly one transition. This monotonicity assumption is what makes "ask about the midpoint, then recurse on one half" valid, it would not be valid for 00 gaps (no known transition at all) which is exactly why those gaps are never binary-searched. Above-threshold 00 gaps are instead handled by a separate checkpoint-sampling mechanism that doesn't assume monotonicity (see step 4); at or below the duration threshold, they're still filtered out before any review begins.
  • The Buffer ROI has no role inside binary search. Interior frames are judged by the reviewer, not by either ROI (Issue #29). The Buffer ROI exists solely to give 1.lmt_gap_fill.py tolerance when deciding whether a gap can be safely auto-resolved; it plays no part once a gap is ineligible for that and reaches binary search. IN NEST/OUT OF NEST answers for a segment's midpoint are a human visual judgment, not a coordinate-based ROI test — no MASS_X/MASS_Y check runs against either ROI during the recursive search. Nest/Buffer ROI overlays (--show-nest-roi/--show-buffer-roi) can optionally be drawn on the review frames as a visual aid, but they default to off and their absence doesn't change what's being asked: the reviewer is answering "is the animal in the nest right now," using their own judgment.
  • Nearest-frame video resolution, not backward-only or exact-only. When a requested global frame doesn't map exactly onto any loaded video's coverage, the script resolves to the nearer of the closest preceding or succeeding available frame (preceding wins exact ties) rather than failing outright. This same resolution strategy is reused in scripts 3 and 4 so that a QC reviewer later sees the same substitute frame the original binary-search reviewer saw for the same gap.
  • Video files are opened once and cached, not reopened for every frame extraction, since a single review session may request hundreds of frames from the same handful of video files.
  • Integrity checks are deliberately strict. The summary report recomputes several frame-count totals through independent paths and raises rather than continues if they disagree. The report's numbers are used as the pipeline's audit trail, so an inconsistency there should stop the run rather than be written down as if it were trustworthy.
  • Nest/Buffer ROI overlay is drawn in one place only (Git Issue #22, + follow-up). _load_frame_into_label() is the single method all three panels (before/QC/after) call to load their image; both overlays are drawn there, once, before the thumbnail resize, using one shared self.nest_roi/self.buffer_roi/self.roi_color/self.roi_thickness. This is what guarantees each overlay is identical across all three panels: it is a single code path per ROI, not three separate implementations that could drift from each other. lmt_common.py's draw_roi_overlay() draws the Nest ROI solid and the Buffer ROI dashed (via a small dashed-edge helper), sharing the same colour/thickness so the two read as one coherent overlay rather than two unrelated ones so both scripts render identically.
  • Neither ROI has a CLI override, by design (Git Issue #22 follow-up). Both are read exclusively from the input file's ROI_METADATA table (written by 1.lmt_gap_fill.py). Allowing a different value on this script's own command line would let the displayed overlay(s) silently diverge from the ROI that actually produced GAP_FILL_ANALYSIS's IN_NEST values for that file. A mismatch that would look plausible on screen while being wrong. If a use case genuinely needs a different ROI, the correct fix is re-running 1.lmt_gap_fill.py, not overriding it here.

Do NOT Modify

  • DB_FPS = 30 and FRAME_CONVERSION = 2 encode the relationship between the LMT database frame rate and the video frame rate; wrong values silently extract the wrong frames.
  • Video filename parsing (get_start_frame()) requires a t<digits> segment immediately before the file extension; any other convention breaks video-to-frame mapping.
  • FILL_SOURCE == "BINARY_SEARCH" must be set only for frames in gaps that were actually routed to the reviewer (i.e. 01/10 gaps above the duration threshold, and 00 gaps above the same threshold via checkpoint review), 3.lmt_qc_sampler.py and 4.lmt_qc_validator.py rely on this distinction.
  • Table name GAP_FILL_ANALYSIS and the FILL_SOURCE column are read by name in 3.lmt_qc_sampler.py and 4.lmt_qc_validator.py. This script no longer writes a BINARY_SEARCH column, but lmt_common.py's compute_qc_pool_mask() still reads one if present, purely to keep reading older files generated before this change (do not remove that fallback).
  • Do not add a --nest-*/--buffer-* CLI argument to this script (Git Issue #22 follow-up). Both ROIs are intentionally sourced only from the input file's ROI_METADATA table; re-adding a CLI override for either would reopen the silent-drift risk described above. If a use case genuinely needs a different ROI than what 1.lmt_gap_fill.py used, the correct fix is re-running 1.lmt_gap_fill.py, not overriding it here.
  • ROI_METADATA's table/column names (see script 1's Do NOT Modify notes, and lmt_common.py's) are read/written by name in lmt_common.py's load_roi_metadata_from_db()/write_roi_metadata(); renaming or dropping any of them makes both the Nest and Buffer ROI silently unavailable (--show-nest-roi/--show-buffer-roi then error out at startup) rather than raising an obvious error at the point of the rename.
  • Nest ROI must stay solid and Buffer ROI must stay dashed in lmt_common.py's draw_roi_overlay() (dashed=False/dashed=True). This is the only thing visually distinguishing the two when both overlays and the same --roi-color are enabled at once; making both solid (or both dashed) would make them indistinguishable on screen.
  • This script must keep propagating ROI_METADATA into its own output (Git Issue #22, 4.lmt_qc_validator.py follow-up). _finish() calls write_roi_metadata() with whatever self.nest_roi/self.buffer_roi it read from its own input, right after writing GAP_FILL_ANALYSIS. Removing this call doesn't break this script itself, but silently breaks 4.lmt_qc_validator.py's overlay several steps downstream, since 3.lmt_qc_sampler.py's output would then have nothing to forward.

Open Source Notes

  • External dependencies: opencv-python (cv2), numpy, pandas, Pillow (PIL.Image, PIL.ImageTk, PIL.ImageDraw, PIL.ImageColor), tkinter.
  • Standard library: argparse, os, re, copy, sqlite3, sys, datetime, time.
  • Configuration files / environment variables: none; database, video(s), and output folder are CLI arguments (-i/--input, -v/--videos, -o/--output-folder). MIN_GAP_DURATION_FOR_BINARY_SEARCH = 30s, FILL_ENTIRE_SEGMENT_IF_DURATION_LESS_THAN_IN_MINUTES = 1 min, and TYPE00_REVIEW_INTERVAL_SECONDS = 60s are hardcoded module-level constants in this script. DB_FPS, FRAME_CONVERSION, FPS_TOLERANCE = 0.5, DEFAULT_ROI_COLOR = "yellow", and DEFAULT_ROI_THICKNESS = 2 are not hardcoded here, they're imported from lmt_common.py (the module shared with scripts 3 and 4), along with draw_roi_overlay(), load_roi_metadata_from_db(), and write_roi_metadata() (Git Issue #22 follow-up: these previously lived as local definitions in this script; centralized so 4.lmt_qc_validator.py draws the identical overlay from identical ROI values rather than an independent copy). --roi-color/--roi-thickness are CLI arguments here, shared by both the --show-nest-roi and --show-buffer-roi overlays; neither ROI itself is a CLI argument at all (see Inputs above).
  • Expected directory structure: none required beyond a writable output folder (_binsearch_tmp is created automatically and cleaned up automatically, including on early window close).
  • Platform assumptions: CLI argument parsing and input validation are headless (no display needed to see a --help message or an invalid-path error), but the review step itself still requires Tkinter + a working OpenCV video backend once setup succeeds; not headless-safe end-to-end. Video codec support depends on the local OpenCV build. Because video files are cached open for the review session, reviewing a very large number of distinct video files in one sitting could approach a platform's open-file-handle limit.

Script: 3.lmt_qc_sampler.py

Overview

This script exists to make the pipeline's output auditable. Scripts 1 and 2 each make decisions (one by static geometric rule, one by human binary search) and both could still be systematically wrong in ways that are hard to notice by inspection alone. Rather than trusting either process blindly, this script draws independently-sized random samples from each category of decision (raw detections, logic-filled gaps, binary-search-filled gaps) so that 4.lmt_qc_validator.py can measure each one's real-world accuracy separately.

Loads a 2.lmt_binary_search.py output, splits rows into three QC "pools" (DETECTED, BINARY_SEARCH, or LOGIC) based on how each frame's classification was produced, draws a proportionally stratified sample of a user-specified total size from each selected pool (allocated across that pool's gaps in proportion to each gap's size, not a uniform random draw across all its frames), extracts the corresponding video frame as a screenshot, and records everything in a new SQLite table for manual review.

Inputs

Input Type Purpose
lmt_binary_search_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite (script 2 output) SQLite database, tables GAP_FILL_ANALYSIS + ROI_METADATA Fully classified per-frame data to draw QC samples from, plus (if present) the Nest/Buffer ROI forwarded from 1.lmt_gap_fill.py via 2.lmt_binary_search.py. Must contain an ANIMALID column. See Animal ID below.
LMT video files (*.mp4) Video Source of the screenshot images for each sampled frame.
Output folder Directory Where per-pool results are written.
Sample count Integer Applied independently to each selected pool.
QC pool selection List (0 or more of DETECTED/BINARY_SEARCH/LOGIC) Any of DETECTED, BINARY_SEARCH, LOGIC rows.

Animal ID is not a user-supplied input: it's read automatically from the input SQLite's ANIMALID column (present since script 1 started writing it). The script hard-fails with a clear error if that column is missing (an older, pre-ANIMALID file), or if the file unexpectedly contains more than one distinct Animal ID.

Outputs

Per selected pool, written to output_folder/{qc_mode}_A<animal_id>_{timestamp}/:

Output Type Purpose
lmt_qc_sampler_<qc_mode>_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite SQLite database, tables QC_ASSUMED_SAMPLES + ROI_METADATA Metadata for the drawn QC sample, plus (if present in the input) the Nest/Buffer ROI, forwarded so 4.lmt_qc_validator.py can auto-load it too (Git Issue #22 follow-up).
Screenshots/S####_A<animal_id>_G<frame>_<video>.png PNG image Extracted video frame for each sampled row.

QC_ASSUMED_SAMPLES columns:

Column Meaning
sample_id 1-based counter, also embedded in the screenshot filename.
animal_id Animal ID, read from the source database's ANIMALID column.
video Basename of the video the screenshot came from.
frame_global The actual (possibly nearest-neighbor-resolved) frame captured.
requested_frame The originally sampled FRAMENUMBER, before any resolution.
IN_NEST Classification carried over from the source row.
ASSUMPTION_TYPE "DETECTED" or "ASSUMED", carried over.
FILL_SOURCE Carried over if present in the source; null if the source predates this column.
GAP_START_FRAME / GAP_END_FRAME Carried over; populated only for ASSUMED rows.
screenshot Filename of the extracted PNG, relative to Screenshots/.
QC_MODE Which pool this row belongs to ("DETECTED" / "BINARY_SEARCH" / "LOGIC").

Processing Steps

  1. Configure the run via CLI arguments: source database, videos, output folder, sample size, and which pools to draw from. Animal ID is read automatically from the source database (see Inputs above), not supplied here.
  2. Guard existing output. If a pool's output folder for today's date already contains files (e.g. from an earlier run), abort with a clear error unless --overwrite was passed, rather than silently overwriting.
  3. Detect the source schema. Whether the file has the modern FILL_SOURCE column, a legacy BINARY_SEARCH flag, or neither, and load GAP_FILL_ANALYSIS (or the legacy ASSUMED_FRAMES table) accordingly.
  4. Filter to the requested pool. DETECTED selects LMT-observed rows    outright; BINARY_SEARCH and LOGIC each select ASSUMED rows with a    resolved (0/1) IN_NEST value and a matching FILL_SOURCE,    deliberately excluding any row still stuck at -1. Attempting to sample    a pool the source file has no data for (e.g. BINARY_SEARCH from a    file that skipped script 2) produces a clear, actionable error rather    than a raw KeyError. The BINARY_SEARCH pool is methodologically mixed. FILL_SOURCE == "BINARY_SEARCH" is set identically for a 01/10 gap's recursive binary-search decisions and for an above-threshold 00 gap's checkpoint-review decisions (see script 2's step 4), there is no separate value, flag, or column distinguishing the two. This script samples and this pool's frames indistinguishably: a 00 gap and a 10 gap compete for the same proportional sample budget, and 4.lmt_qc_validator.py reports one accuracy number across both. If checkpoint-review accuracy and binary-search accuracy need to be measured separately, that split isn't available from this pool as-is.
  5. Draw a proportionally stratified sample, bounded to the pool's actual size. The requested sample count is allocated across the pool's distinct gaps in proportion to each gap's frame count (largest-remainder apportionment, so the allocation sums exactly to the request), then that many frames are drawn uniformly at random within each gap, this avoids a plain uniform draw over all frames being dominated by a handful of very large gaps. An explicit, freshly-generated random seed is used and reported back to the user: the draw is still effectively random every run, but the exact sample can be reproduced later if the seed is recorded.
  6. Resolve and extract a screenshot for every sampled frame, using the same nearest-available-frame strategy as script 2, recording both the requested and resolved frame numbers so a reviewer can see whether (and how far) a substitution was made.
  7. Write the pool's SQLite table and screenshot folder, and report a per-pool summary including the sampling seed used. If the input file had a ROI_METADATA table, it's forwarded unchanged into this pool's own output (Git Issue #22 follow-up). This script itself doesn't use the ROI for anything (no overlay, no filtering); it only relays it so 4.lmt_qc_validator.py has something to auto-load, several steps downstream of 1.lmt_gap_fill.py.

Key Design Decisions & Assumptions

  • Pool-based sampling, not one pooled-together sample. DETECTED, LOGIC, and BINARY_SEARCH rows represent three structurally different sources of potential error (raw detector noise, a static geometric heuristic, and human judgment), so each is sampled and later scored independently rather than being mixed into a single accuracy number that would obscure which stage of the pipeline is actually underperforming.
  • Frame resolution logic is intentionally identical to script 2's, so that whatever substitution happened for a given frame in binary search is reproduced consistently here rather than independently re-derived (and potentially diverging).
  • Video files are cached open for the duration of a sampling run and released once all selected pools finish, for the same performance reason as script 2.

Do NOT Modify

  • Table name QC_ASSUMED_SAMPLES and all listed column names are required by 4.lmt_qc_validator.py's load_database().
  • The screenshot filename format (S{counter:04d}_A{animal_id}_G{resolved_frame}_{video_name}.png) must remain resolvable relative to the Screenshots/ subfolder for 4.lmt_qc_validator.py to locate images.
  • Every row written for a given output file must carry the same QC_MODE value, script 4 reads it from the first row only and does not support a mixed-mode file.
  • This script must keep forwarding ROI_METADATA into its per-pool output (Git Issue #22, 4.lmt_qc_validator.py follow-up). run() reads it from analysis_db via load_roi_metadata_from_db() and must call write_roi_metadata() on out_db before closing it. Removing this call doesn't break this script's own sampling, but silently breaks 4.lmt_qc_validator.py's overlay downstream, since it would then have nothing to auto-load.

Open Source Notes

  • External dependencies: opencv-python (cv2), pandas.
  • Standard library: argparse, os, re, random, sys, sqlite3, datetime.
  • Configuration files / environment variables: none; database, video(s), output folder, sample size, and pool selection are CLI arguments (-i/--input, -v/--videos, -o/--output-folder, -n/--samples, --pools); DB_FPS, FRAME_CONVERSION, and FPS_TOLERANCE are imported from lmt_common.py (the module shared by scripts 2, 3, and 4), not hardcoded here; the three QC_MODE_* constants are likewise imported from lmt_common.py, not duplicated from 2.lmt_binary_search.py (which doesn't define them at all). load_roi_metadata_from_db()/write_roi_metadata() (Git Issue #22 follow-up) are also imported from lmt_common.py, this script has no ROI CLI arguments and no overlay of its own; it purely relays whatever ROI its input had.
  • Expected directory structure: creates {output_folder}/{qc_mode}_A<animal_id>_{timestamp}/Screenshots/ automatically.
  • Platform assumptions: none; fully headless, no GUI toolkit is used.

Script: 4.lmt_qc_validator.py

Overview

This script exists to close the loop: it is the only part of the pipeline that produces an actual accuracy number. Everything upstream is a mechanism for producing a classification; this script is where a human directly compares that classification against what they can see with their own eyes, for a statistically meaningful sample, and where the pipeline's real-world error rate is finally measured rather than assumed.

Loads a 3.lmt_qc_sampler.py output, determines the active QC mode from the QC_MODE column (with legacy fallbacks), filters to the eligible rows for that mode, and presents each sampled screenshot, plus, for ASSUMED-type modes, the gap's before/after boundary frames re-extracted from video, to a human reviewer for manual "IN NEST"/"OUT OF NEST" labeling. Saves progress after every label, and on completion computes a two-class confusion matrix (algorithm prediction = IN_NEST vs. human ground truth = MANUAL_QC) and writes a text validation report.

Inputs

Input Type Purpose
lmt_qc_sampler_<qc_mode>_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite (script 3 output) SQLite database, tables QC_ASSUMED_SAMPLES + ROI_METADATA The drawn QC sample to label, plus (if present) the Nest/Buffer ROI forwarded from upstream.
Screenshot folder Directory of PNGs Location of images referenced by the screenshot column.
LMT video files (optional) Video Enables re-extracting gap boundary frames for the three-panel view.

Neither the Nest ROI nor the Buffer ROI is a CLI input to this script at all (Git Issue #22 follow-up): both are read exclusively from the input file's ROI_METADATA table. If --show-nest-roi and/or --show-buffer-roi is passed and the corresponding ROI isn't available there (e.g. any file earlier in this run's 1234 chain predates ROI_METADATA), the script errors out at startup rather than silently skipping the overlay.

Outputs

Output Type Purpose
lmt_qc_validator_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.sqlite (or lmt_qc_validator_Aunknown_... if the source lacked an animal_id column; written into the screenshot folder) SQLite database, table QC_ASSUMED_SAMPLES The same sample table with human labels recorded.
lmt_qc_validator_A<animal_id>_<YYYY-MM-DD_HH-MM-SS>.txt Text report Confusion matrix and accuracy/error-rate/sensitivity/specificity metrics, plus false-positive/false-negative screenshot lists.

Columns are identical to script 3's output table, plus:

Column Meaning
MANUAL_QC 1 = human says IN NEST, 0 = human says OUT OF NEST, null/NaN = not yet labeled.

Processing Steps

  1. Load the sample and resolve the active QC mode from the first row's QC_MODE value (falling back to a legacy "ASSUMED" mode for older files), then re-apply the same eligibility filter script 3 used to build that pool, a defensive re-check against a hand-edited or unexpected input file.
  2. Present one sample at a time. DETECTED-mode samples show the single pre-extracted screenshot. BINARY_SEARCH/LOGIC/legacy ASSUMED samples show a three-panel view, last known frame before the gap, the sampled QC frame, and first known frame after the gap, all re-extracted live from video using the same nearest-frame resolution strategy as scripts 2 and 3, so the reviewer sees the same context the original binary-search reviewer had for that gap.
  3. Record each label immediately. Pressing IN NEST or OUT OF NEST writes to MANUAL_QC and persists it before advancing; the first label of a session creates the output file with a full save, and every subsequent label updates only that one row rather than rewriting the whole table.
  4. Compute the confusion matrix once every sample is labeled: positive class = IN NEST, negative class = OUT OF NEST, algorithm prediction = IN_NEST, ground truth = MANUAL_QC. Any row whose IN_NEST is outside {0, 1} (which should never happen given script 3's filtering, but is checked defensively) is excluded and reported rather than silently folded into "predicted OUT."
  5. Write the report, including accuracy, error rate, sensitivity, and specificity, and explicit lists of which screenshots were false positives/negatives, so a reviewer can go back and visually audit the pipeline's specific mistakes rather than only seeing an aggregate score.
  6. Load and apply Nest/Buffer ROI overlays, if requested (Git Issue #22 follow-up). Before any of the above, start_qc() reads the input file's ROI_METADATA table (if present) and, for every displayed frame at every site above (three-panel and both single-panel fallbacks) draws the Nest ROI solid and/or the Buffer ROI dashed via the shared _apply_roi_overlays() helper, before that frame is thumbnailed and shown.

Key Design Decisions & Assumptions

  • The same nearest-frame video resolution logic is used across scripts 2, 3, and 4 on purpose. Accuracy validation is only meaningful if the reviewer here is looking at the same substitute frame the pipeline actually used to make its decision (or the same one the binary-search reviewer originally judged).
  • Metrics defensively exclude out-of-range predictions rather than mis-binning them. A stray IN_NEST value outside {0, 1} reaching this stage indicates a bug upstream, not a genuine "predicted OUT" case, and should be visible as a warning rather than quietly skewing the reported accuracy.
  • Per-row incremental saves. Since a validation session can involve hundreds of individual label clicks, persisting only the one row that changed (via an UPDATE) rather than rewriting the entire table on every click keeps the save cost independent of the total sample size.
  • One shared overlay helper for every display site, not three separate ones (Git Issue #22 follow-up). Unlike 2.lmt_binary_search.py (which has a single method feeding all three panels), this script has three distinct display sites (three-panel boundary frames via extract_frame_to_label(), the ASSUMED-mode no-boundaries fallback, and the DETECTED-mode single panel). _apply_roi_overlays() centralizes the actual drawing logic so all three sites stay behaviorally identical; each site still calls it individually since each has a different image source (a temp PNG from video vs. the sampler's pre-extracted screenshot).
  • Neither ROI has a CLI override here either, for the same reason as 2.lmt_binary_search.py. Allowing a different value on this script's own command line would let the displayed overlay silently diverge from the ROI that actually produced the IN_NEST values being validated, exactly the kind of mismatch that would look plausible on screen while undermining the accuracy measurement this script exists to produce.

Do NOT Modify

  • This is the terminal script in the pipeline; its report format (TP/FP/TN/FN, accuracy, error rate, sensitivity, specificity) is the pipeline's canonical accuracy measurement.
  • The QC_MODE value is read from the first row only of the loaded table to choose filter/display logic for the whole session, input files must not mix QC modes.
  • screenshot values must remain resolvable as os.path.join(screenshot_folder, screenshot_nm), this script must be pointed at the same folder script 3 wrote screenshots into.
  • The incremental per-row save relies on sample_id existing and being unique per row, as guaranteed by script 3's output.
  • Do not add a --nest-*/--buffer-* CLI argument to this script (Git Issue #22 follow-up). Both ROIs are intentionally sourced only from the input file's ROI_METADATA table, forwarded through the pipeline; re-adding a CLI override for either would reopen the silent-drift risk described above.
  • ROI_METADATA's table/column names (see script 1's Do NOT Modify notes, and lmt_common.py's) are read by name in load_roi_metadata_from_db(); renaming or dropping any of them makes both overlays silently unavailable (--show-nest-roi/ --show-buffer-roi then error out at startup) here exactly as in 2.lmt_binary_search.py.
  • Nest ROI must stay solid and Buffer ROI must stay dashed, same constraint as 2.lmt_binary_search.py, this is the only thing visually distinguishing the two when both overlays and the same --roi-color are enabled at once.

Open Source Notes

  • External dependencies: opencv-python (cv2), pandas, Pillow (PIL.Image, PIL.ImageTk, PIL.ImageColor), tkinter.
  • Standard library: argparse, os, re, sys, sqlite3, datetime, tempfile, uuid.
  • Configuration files / environment variables: none; database, optional video(s), and screenshot folder are CLI arguments (-i/--input, -v/--videos, -o/--screenshot-folder); DB_FPS, FRAME_CONVERSION, FPS_TOLERANCE, the QC mode constants, DEFAULT_ROI_COLOR, DEFAULT_ROI_THICKNESS, draw_roi_overlay(), and load_roi_metadata_from_db() are all imported from lmt_common.py (the module shared by scripts 2, 3, and 4), not hardcoded or duplicated from any other script. --roi-color/--roi-thickness are CLI arguments here (shared by both overlays); neither ROI itself is a CLI argument at all (see Inputs above).
  • Expected directory structure: expects the screenshot folder produced by 3.lmt_qc_sampler.py.
  • Platform assumptions: CLI argument parsing and input validation are headless (no display needed to see a --help message or an invalid-path error), but the manual labeling step itself still requires Tkinter + OpenCV once setup succeeds; not headless-safe end-to-end. Uses the OS temp directory for scratch boundary-frame images (always cleaned up, including on error). Video files are cached open for the session and released when the window is closed.

Script: lmt_common.py

Overview

This module exists to guarantee one thing the pipeline's accuracy depends on: that scripts 2.lmt_binary_search.py, 3.lmt_qc_sampler.py, and 4.lmt_qc_validator.py resolve a given video filename and a given requested global frame number to exactly the same substitute frame, every time. It is not a script, it has no GUI, no __main__ entry point, and is never run directly. It is a shared library, imported by all three scripts above, holding the video/frame-resolution logic and the QC-pool eligibility rules that used to be three independently hand-maintained copies of the same code.

3.lmt_qc_sampler.py draws its QC sample pool using this module's frame-resolution logic, and 4.lmt_qc_validator.py measures accuracy by comparing a human's manual label against the label the pipeline assigned, that comparison is only meaningful if both scripts (and 2.lmt_binary_search.py, which produced the underlying FILL_SOURCE bookkeeping (plus a legacy BINARY_SEARCH column on files predating it) in the first place) agree on how a frame number maps to actual video content. Before this module existed, that agreement was enforced by hand-editing three copies in lockstep; this module makes it structural instead.

Provided to callers

Export Kind Purpose
DB_FPS, FRAME_CONVERSION, EXPECTED_VIDEO_FPS, FPS_TOLERANCE Constants Encode the relationship between the LMT database frame rate and the video frame rate, and how much a video's actual fps may deviate from that before it's flagged.
QC_MODE_DETECTED, QC_MODE_BINARY_SEARCH, QC_MODE_LOGIC, QC_MODE_ASSUMED Constants Identify which QC sample pool a caller is filtering for or sampling from.
get_start_frame(video_name) Function Parses a video filename's t<digits> segment into its starting global frame number.
get_video_frame_count_and_fps(video_path) Function Opens a video once and returns (frame_count, fps).
build_video_map(video_paths) Function Builds the sorted {start, end, path} map used to route a global frame number to a specific video file, and reports any videos that were excluded (unparseable filename) or whose fps deviates beyond tolerance.
find_nearest_frame_candidates(video_map, global_frame) Function Resolves a requested global frame to the video(s) that can actually supply it, including the nearest-available-frame fallback logic used when the exact frame isn't covered by any video.
_read_frame_from_video(video_entry, resolved_frame, out_path) Function Seeks to and writes a single resolved frame out to disk, using the shared, cached cv2.VideoCapture handles.
_release_all_captures() Function Releases every cached cv2.VideoCapture handle; each script's cleanup/close path calls this.
compute_qc_pool_mask(df_full, qc_mode) Function Returns (mask, label): the boolean row mask and a human-readable pool name for a requested QC mode (DETECTED / BINARY_SEARCH / LOGIC / ASSUMED), given a GAP_FILL_ANALYSIS-shaped DataFrame. Includes the fallback logic for older-format outputs that predate the FILL_SOURCE column.
DEFAULT_ROI_COLOR, DEFAULT_ROI_THICKNESS Constants Shared default overlay outline colour ("yellow") and thickness (2px) for --roi-color/--roi-thickness in scripts 2 and 4 (Git Issue #22).
load_roi_metadata_from_db(conn) Function Reads the one-row ROI_METADATA table from conn (if present) and returns (nest_roi, buffer_roi) dicts, or (None, None) if absent. Used by scripts 2 and 4 to auto-load, and by script 3 to read-and-forward.
write_roi_metadata(conn, animal_id, nest_roi, buffer_roi) Function Writes (or overwrites) the one-row ROI_METADATA table into conn; no-ops if both ROIs are None. Used by scripts 2 and 3 to propagate the ROI they read forward into their own output, so it eventually reaches script 4.
draw_roi_overlay(img, roi, color, thickness, dashed=False) Function Draws a Nest (dashed=False, solid) or Buffer (dashed=True) ROI outline on a PIL Image, in place. Used identically by scripts 2 and 4 so their overlays render the same way.
_draw_dashed_edge(draw, start, end, color, thickness, ...) Function Draws one dashed straight edge; internal helper for draw_roi_overlay()'s dashed=True case.

Each script also keeps its own thin, script-specific wrapper around this module's frame-reading primitives (extract_frame_to_path in script 2, extract_frame in script 3, extract_frame_to_label in script 4), these differ in return signature and, for script 4, drive a GUI widget directly, so they are intentionally not part of this shared module. Similarly, each script that displays an overlay (2.lmt_binary_search.py, 4.lmt_qc_validator.py) keeps its own small call site wiring draw_roi_overlay() into its own frame-display code, since each has a different image-loading path; only the drawing logic itself (and the ROI read/write) is centralized here.

Key Design Decisions & Assumptions

  • Frame-resolution logic was extracted, not reimplemented; the ROI functions (Git Issue #22 + follow-ups) were added here directly, not extracted from an earlier per-script copy. get_start_frame(), build_video_map(), find_nearest_frame_candidates(), _read_frame_from_video(), _release_all_captures(), and compute_qc_pool_mask() are verbatim moves from 2.lmt_binary_search.py's original copy. load_roi_metadata_from_db(), write_roi_metadata(), draw_roi_overlay(), and _draw_dashed_edge() were first written and used inside 2.lmt_binary_search.py (Git Issue #22's original implementation), then moved here as a follow-up specifically so 4.lmt_qc_validator.py could draw an identical overlay from identical ROI values, without a second, independently-drifting copy of that logic living in each script.
  • A single in-process video-capture cache. _get_capture()'s cache and _release_all_captures() are now shared across every script that imports this module within one process, this matches each script's existing single-session, single-process usage pattern and requires no change to caller behavior.
  • compute_qc_pool_mask() centralizes pool eligibility, not just frame resolution. This was originally two independent implementations (3.lmt_qc_sampler.py's filter_pool() and an inline block in 4.lmt_qc_validator.py's load_database()) that had already begun to drift. Script 4 had a QC_MODE_ASSUMED legacy-pool branch script 3 did not. Both call sites now go through the same function.
  • write_roi_metadata() no-ops rather than writing an all-None row. If both nest_roi and buffer_roi are None (nothing to propagate, e.g. this run's own input predated ROI_METADATA), the function does nothing rather than writing a row of nulls, so "the table is absent" and "this run legitimately had no ROI" never get conflated by a downstream reader.

Do NOT Modify

  • Any change to get_start_frame(), build_video_map(), or find_nearest_frame_candidates() changes frame resolution for all three calling scripts simultaneously.
  • DB_FPS = 30 and FRAME_CONVERSION = 2 must stay in sync with the actual LMT database and video export frame rates; wrong values silently extract the wrong frames across every script that imports this module.
  • compute_qc_pool_mask()'s fallback branches (used when FILL_SOURCE is absent) must stay behaviorally identical to script 3's original filter_pool() logic they exist specifically to keep older, pre-FILL_SOURCE 2.lmt_binary_search.py outputs sampling/validating correctly.
  • Table name ROI_METADATA and its eight column names (see script 1's Do NOT Modify notes) are read/written by name in load_roi_metadata_from_db()/write_roi_metadata(); renaming or dropping any of them silently breaks ROI auto-load in every script that imports this module (scripts 2 and 4 then treat the ROI as simply absent, rather than raising an error at the point of the rename).
  • Nest ROI must stay solid (dashed=False) and Buffer ROI must stay dashed (dashed=True) in draw_roi_overlay(). Every caller (scripts 2 and 4) relies on this to keep the two overlays visually distinguishable.

Open Source Notes

  • External dependencies: opencv-python (cv2), pandas (for load_roi_metadata_from_db()/write_roi_metadata()), Pillow (PIL.ImageDraw, for draw_roi_overlay()).
  • Standard library: os, re.
  • Configuration files / environment variables: none; all constants (including DEFAULT_ROI_COLOR/DEFAULT_ROI_THICKNESS) are hardcoded module-level values.
  • Expected directory structure: none; this module has no file I/O of its own beyond frame extraction to a caller-supplied path and reading/writing the ROI_METADATA table on a caller-supplied connection.
  • Platform assumptions: same as any importing script, depends on a working OpenCV video backend; not headless-relevant on its own since this module has no GUI code.

Testing (tests/)

Overview

The correctness-critical pure logic in this pipeline (gap-type classification, binary-search and type-00-checkpoint task generation/hand-off, frame resolution, and the gap-fill ROI/vectorization core) has an automated pytest suite covering it, independent of the Tkinter GUIs and real video/database files. This exists so a regression in that logic (e.g. the kind of silent defaulting bug fixed elsewhere in this pipeline's history) surfaces as a fast, deterministic test failure instead of only being catchable by manually reviewing GUI output against real data. The generic _check integrity-assertion helper is unit-tested in isolation (see What's Covered below), but the actual integrity-accounting code paths that call it write_summary_report's balance checks and _finish's unresolved-frame check are not exercised by the suite.

How to Run

uv run pytest tests/ -v

No video files, SQLite databases, or GUI interaction are required, the suite runs fully headless.

What's Covered

  • tests/test_binary_search_logic.py (2.lmt_binary_search.py): classify_gap_type, the generic _check helper (tested standalone with arbitrary values, not through an actual report/accounting call site), build_initial_tasks, find_nearest_frame_candidates (via lmt_common.py).
  • tests/test_gap_fill_logic.py (1.lmt_gap_fill.py): the gap-expansion and ROI-membership vectorization core.
  • tests/test_type00_gap_review.py (2.lmt_binary_search.py): _build_type00_checkpoint_tasks, the type-00 branch of _handle_answer (including the checkpoint-to-binary-search hand-off on the first IN answer), and Skip/Undo/Redo against type-00 tasks.
  • tests/test_preprocessing_dedup.py (0.Preprocessing.py, plus one end-to-end case into 1.lmt_gap_fill.py): process_database's deduplication (Case A exact duplicates, Case B conflicting rows, distinct-ANIMALID rows left alone, -1 FRONT_*/BACK_* rows no longer removed, a surrogate primary key excluded from identity comparison), the original file being left untouched, the overwrite guard, a missing DETECTION table, and 1.lmt_gap_fill.py's duplicate-FRAMENUMBER defensive check (Issue #5) both firing on raw non-deduplicated input and staying silent on deduplicated input. Runs against real temporary SQLite files (via tmp_path), not synthetic in-memory DataFrames, since deduplication is fundamentally a file-level operation.
  • Not covered: write_summary_report's and _finish's integrity/balance-check logic, any of the four scripts' CLI argument parsing, the interactive Tkinter GUI code paths in scripts 2 and 4 (image display, button/keyboard callbacks wiring, window lifecycle), and, as of Git Issue #21/#22, the summary report's CLI-input/ROI-logging lines, lmt_common.py's draw_roi_overlay()/_draw_dashed_edge()/load_roi_metadata_from_db()/write_roi_metadata(), both scripts' overlay call sites (2.lmt_binary_search.py's _load_frame_into_label(), 4.lmt_qc_validator.py's extract_frame_to_label() and both show_sample() single-panel fallbacks via _apply_roi_overlays()), ROI_METADATA persistence/propagation across all four scripts, and the --roi-color/--roi-thickness CLI validation in scripts 2 and 4 (_validate_cli_args; there is no ROI-value CLI validation left to cover in either script, since neither ROI is a CLI argument anywhere in the pipeline). These were exercised manually (real pipeline runs through the full 1234 chain, a headless-Tk/Xvfb harness, and direct calls against synthetic data) rather than by an addition to this pytest suite; adding automated coverage for them is a natural follow-up.

Key Design Decisions & Assumptions

  • Scripts are loaded by file path, not imported normally. The pipeline's numerically-prefixed filenames (1.lmt_gap_fill.py, etc.) aren't valid Python module names, so tests/conftest.py loads them via importlib.util.spec_from_file_location rather than a standard import statement.
  • The repo root is added to sys.path for the duration of the test session. A script loaded this way still needs its own top-level imports (e.g. 2.lmt_binary_search.py's from lmt_common import ...) to resolve; python script.py gets this for free by putting the script's own directory on sys.path automatically, but importlib-based loading does not, so conftest.py does it explicitly.
  • 1.lmt_gap_fill.py and 3.lmt_qc_sampler.py guard their CLI entry point (argparse parsing + execution) behind if __name__ == "__main__":, and 4.lmt_qc_validator.py guards its GUI bootstrap the same way; 0.Preprocessing.py and 2.lmt_binary_search.py already followed this pattern. This is required for these files to be importable at all without triggering argparse's sys.exit() (scripts 0, 1, and 3) or opening a live Tkinter window (script 4). Interactive/CLI behavior (uv run python <script>.py ...) is unchanged by this guard.
  • The gap-fill vectorization test re-implements the core ROI/gap-expansion logic inline (tests/test_gap_fill_logic.py's _run_core_logic helper) rather than calling run_analysis() directly, since that function also performs DB I/O not relevant to the logic under test.

Do NOT Modify

  • tests/conftest.py's sys.path insertion: removing it will reintroduce ModuleNotFoundError: lmt_common for any test that loads 2.lmt_binary_search.py.
  • The main guards in 0.Preprocessing.py, 1.lmt_gap_fill.py, 3.lmt_qc_sampler.py, and 4.lmt_qc_validator.py, removing them breaks headless test loading for those scripts (and, for any future test added against them, would trigger CLI argument parsing or open a GUI window during pytest collection).

Open Source Notes

  • External dependencies: pytest>=8.0.0 (dev dependency only, not required to run the pipeline itself).
  • Standard library: importlib.util, pathlib, sys.
  • Configuration files: pyproject.toml's [dependency-groups] dev section.
  • Platform assumptions: none beyond what the pipeline scripts themselves require. The suite is fully headless and does not depend on a display server, video codec support, or a live SQLite file

About

A quality control pipeline for Live Mouse Tracker (LMT) behavioral tracking data using automated gap inference and human-in-the-loop validation.

Resources

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages