A portable, reloadable snapshot of one trial's analysis - everything except
the video itself. Save one from the app after tracking ("Save project
file"), open it later on any computer, and the app restores the trial
without re-tracking. examples/ has a real one for each of the three
sample clips.
Implemented in app/js/project-file.js.
This file is for the tool: complete, lossless, resumable. The CSV/XLSX export is for a human and their stats software: summarised and lossy on purpose. Neither replaces the other, and they are deliberately not the same file.
The video is not in it. Videos are enormous and the user already has the one this came from, so the file carries a fingerprint of it instead (see below) and asks for it back when reopened.
JSON, UTF-8, 2-space indent, never minified. A researcher may open this in
a plain text editor on a machine with nothing better installed, so field
names are real words (frame, cx, cy, valid) rather than compact
shorthand, and the indentation is there so the structure can be skimmed by
eye.
{
"format": "barnes-maze-pipeline-session",
"formatVersion": 2,
"toolVersion": "0.1.0",
"savedAt": "2026-09-05T18:44:23.333Z",
"video": { ... }, // fingerprint of the video this came from
"roi": { ... }, // platform, holes, target, scale
"metadata": { ... }, // trial/cohort/group/animal, free text
"thresholds": { ... }, // every parameter, whether or not it was used
"results": { ... }, // trajectory + per-hole signals
"overrides": { ... }, // the human corrections
"flags": { ... } // what was flagged for review
}
fileName, fileSize, lastModified, totalFrames, timescale,
trackDuration, width, height, codec, duplicateTimestampFrames
(a count).
totalFrames, timescale and trackDuration together are the exact
rational frame rate, not a rounded decimal - test51 is 15000/1001 fps, not
15. All timing on reload is recomputed from these, never from an assumed
fps.
On reload the app re-checks the file you hand it against this section
(checkVideoFingerprint). It is strict about anything that would silently
corrupt the analysis - a different frame count, a different duration or
frame rate, different dimensions, a different file size all refuse the
load, and the refusal names both values ("expected 9000 frames, this video
has 8991") rather than just saying it doesn't match. A changed filename
warns and continues, because a rename doesn't change the video.
lastModified is deliberately never checked: copying a file changes it.
platform ({x, y, r}), holes (20 of the same), targetHoleIndex,
holeStart (whether 0- or 1-based hole numbering was on screen when this
was saved - recorded rather than assumed, so a reload and an export always
agree), diameterCm (the real-world scale, what makes path length and
speed reportable in cm), and edited (whether a human moved any of it).
number, cohort, group, animalId, priorTrials - free text exactly
as entered, never parsed by anything. Animal IDs keep their leading zeros
here and all the way through the XLSX export.
All ten, in one flat object: maskThreshold, minArea, maxArea,
minCenterDensity, minRun, gapTolerance, speedRatio,
reengageFraction, serialBar, randomBar.
The full set is always written, even when a stage was never run in that session. A missing threshold is ambiguous when you come back to the file later; an unused one is not.
trajectory- one entry per frame of the whole video:{frame, cx, cy, area, valid}. A frame the tracker could not resolve is written asvalid: falsewithcx/cy/areanull. It is never interpolated and never dropped (hard rule #1), so the array index and the video's frame number always agree.trialStartFrame,trackedCount,failedCount,totalFrames.holeSignals.centerDensityandholeSignals.rimDensity- one array per hole, one value per frame. This is the primary nose-poke evidence: pixel change inside each hole measured against the median background, computed independently of where the tracked blob was.
eventOverrides is keyed "holeIndex:startFrame:endFrame", valued with
what the human said it actually was ("poke", "entry",
"thigmotaxis", ...). classifierOverride is the human-confirmed search
strategy verdict, if one was set.
This is the most valuable content in the file - the only part that cannot be recomputed from anything else in it. Everything else here is either measured from the video or typed in once; these are judgment calls somebody made while watching the footage.
cellFlags (which fields were flagged as needing review) and videoFlag
(a whole-video flag with its reasons and note, or null).
Events, merged visits, measures, and the search-strategy verdict are all absent, and that is on purpose. Each is a cheap, pure recomputation from the trajectory + thresholds + overrides that are here, and the app already recomputes them fresh rather than trusting a cached value. Freezing them into this file would create a second source of truth that could silently drift out of sync with the code that produced it.
The practical consequence: reopening an old project file gives you the current build's analysis of the original tracking data, not a replay of an old build's conclusions.
formatVersion is currently 2. Loading is refused in both directions:
- Newer than this build - "saved by a newer version of the tool (format v3, this build only understands up to v2)."
- Older than this build - also refused, with a re-track instruction. This sounds harsher than necessary, but v1 files are exactly the ones affected by the serialization bug the version bump exists to flag, and no migration path was written for them.
Version 1 → 2 was bumped the day a real bug was fixed: centerDensity and
rimDensity are Float32Arrays in memory, and JSON.stringify serializes
a typed array as {"0":0.1,"1":0.2,...} - an object with no .length.
Every reload silently found zero events, because the qualifying-run loop's
i < n never ran. They are converted to real arrays on write now, and the
version number says so.
Independently of the version check, the loader also validates the shape of the hole-signal payload and refuses anything that isn't real arrays. The version bump stops that specific bug recurring; the shape check stops any future regression shaped like it, version bump or not. Both would rather refuse a file than analyse empty data.