Skip to content

feat: record + replay vision pipelines from file logs#2491

Open
JosephTLockwood wants to merge 154 commits into
PhotonVision:mainfrom
JosephTLockwood:feature/file-log-replay
Open

feat: record + replay vision pipelines from file logs#2491
JosephTLockwood wants to merge 154 commits into
PhotonVision:mainfrom
JosephTLockwood:feature/file-log-replay

Conversation

@JosephTLockwood

Copy link
Copy Markdown
Contributor

Summary

Adds end-to-end file-log recording and in-place replay to PhotonVision so
robot-side vision pipelines can be tuned against captured frames after a match
without re-running the original camera. Builds on (and supersedes) the recording
work in #2183.

Recording

  • FrameRecorder writes a JPEG image-sequence (frames/000000.jpg …) plus a
    metadata.jsonl sidecar ({"seq":N,"capture_ns":T}) atomically, one frame
    per file. Chose this over an MP4/AVI container because:
    • no 2 GB container size cap; truncatable by file count after a crash,
    • random seek for free,
    • no codec compatibility risk (built into every OpenCV build), and
    • a half-flushed file at process death loses one frame, not the recording.
  • Recording is toggled via the recording / recordingRequest NT topics, or
    from robot code through new PhotonCamera.setRecording(boolean) /
    isRecording() (Java, C++, Python).
  • A tss.json is written alongside the sidecar at recording start, capturing
    the time-sync-server offset so per-pipeline JSON exports can later place
    capture_ns into the TSS time base for AKit replay.

Replay

  • New FileLogFrameProvider decodes frames/ + metadata.jsonl back into the
    vision pipeline at the recorded cadence, propagating capture_ns verbatim
    into Frame.timestampNanos so downstream NT pose observations carry the
    original capture timestamp.
  • Replay is in-place: VisionModule.startReplay(recording) swaps the live
    FrameProvider on the running VisionSource, runs the pipeline against the
    recorded frames, and swaps back at EOF (or on explicit cancel). No second
    VisionModule instance, no parallel runner.
  • A JsonResultExporter tees each CVPipelineResult to a per-pipeline-hash
    .json file under the recording directory while a replay is active, so the
    user can re-tune pipeline parameters and diff result sets across runs.

NT contract additions

  • Per-camera subtable: recording, recordingRequest, isReplaying,
    replayProgressCurrentFrame, replayProgressTotalFrames,
    replayProgressRecordingName.

UI

  • New `RecordingsCard.vue` on the General Settings page: per-camera list of
    recordings, inline processed-stream preview during replay, per-row Replay
    button + results dropdown with auto-download on replay end.
  • New top-of-app `ReplayBanner.vue` showing live replay progress across all
    cameras, polled through a `useReplayStatus` composable against a new
    `GET /api/recordings/replay/status` endpoint.

Server

  • Recording lifecycle endpoints (`exportIndividual`, `exportCamera`,
    `export`, `delete`, `nuke`) plus replay endpoints (`replay`,
    `replay/cancel`, `replay/status`, `results`, `result`). All
    user-supplied paths are routed through a new `PathSafety.safeResolve`
    helper to block traversal.

Testing

  • New JUnit coverage for the recording → sidecar invariant, the provider's
    parse-and-pace path, the JSON exporter's TSS-shifting math, and the
    end-to-end record → replay → export round-trip (`JsonResultEndToEndTest`,
    `FrameRecorderTssSnapshotTest`, `FileLogFrameProviderTest`,
    `MetadataSidecarReaderTest`, `VisionSourceFrameProviderSwapTest`, …).
  • Existing `VisionSourceManagerEnumerationTest` extended to cover the
    recorded-source enumeration / filter path.
  • Smoke-tested locally on WSL2 Linux (the deployment target) against a real
    USB camera: record → replay → in-browser preview → JSON download.

Test plan

  • CI green across all platforms
  • Record from a USB camera, stop, replay through the per-row button — UI
    shows progress, banner appears, results dropdown populates on EOF
  • `PhotonCamera.setRecording(true)` from a robot project flips the
    `recording` topic and lights up the indicator
  • Replay survives a pipeline parameter change mid-replay (results re-export
    under a new hash)
  • Export-all zip contains `//{frames/, metadata.jsonl,
    tss.json}` for every recording

Notes for reviewers

  • Provider-swap entry/exit is gated through `VisionSource.getFrameProvider`
    / `setFrameProvider` so every camera type (USB, libcamera, file, test)
    shares the same swap surface — no per-source code paths.
  • `FileLogFrameProvider.setRecording` is a deliberate no-op (with a warn on
    attempted-true) because it is called from the NT listener thread; throwing
    would propagate into the NT4 listener pool. Same fix applied to
    `FileVisionSource` / `LibcameraGpuSource`.
  • The JPEG quality knob is 85 (~50–150 KB/frame at 1080p). Not exposed yet.

JavaDoc line-wrap drift collected on six files after recent edits.
spotlessApply rewraps long paragraphs and collapses one method-call
linebreak in FrameRecorder.sampleTssNow.
@JosephTLockwood JosephTLockwood requested a review from a team as a code owner May 15, 2026 22:36
@github-actions github-actions Bot added frontend Having to do with PhotonClient and its related items photonlib Things related to the PhotonVision library backend Things relating to photon-core and photon-server labels May 15, 2026
@JosephTLockwood

Copy link
Copy Markdown
Contributor Author

Heads-up for reviewers: while auditing the diff before opening I flagged two slices of pre-fork code from #2183 that this PR carries forward but doesn't otherwise depend on. Calling them out explicitly so a reviewer can decide whether to keep, finish, or drop them — happy to cut either in a follow-up commit if the answer is "drop":

1. Reserve-recording-space (~310 LOC, 7 files) — robot-code-triggered "delete oldest recordings to free disk for an upcoming match" workflow. Files:

  • HardwareManager.reserveRecordingSpace(VisionModule[]) + reserveRecordingSpace(double)
  • NetworkTablesManager reserve subscribers + onReserveRecordingSpaceChanged + getMatchData
  • NTDriverStation.compareMatchData + the printMatchData private→public+return-String refactor
  • PhotonUtils.reserveSpace() and PhotonCamera.willRecord() — new public photon-lib API
  • VisionModule.recordingSpaceNeeded() — only called from HardwareManager.reserveRecordingSpace

Things to weigh: the match-data sort relies on a substring(0, 1) heuristic over the printMatchData string ("Event xxx, Match Q12, Replay 0, ..."), which is fragile vs. the structured types the rest of the codebase uses; and reserveSpace() / willRecord() are new public photon-lib methods that lock us into supporting them.

Replay does not depend on any of this; if it's dropped, the recorder just stops accepting frames when disk fills (existing low-disk-space gate at FrameRecorder.MIN_DISK_SPACE_BYTES).

2. RecordingStrategy enum (~30 LOC, 4 files + 3 frontend) — the enum has exactly one value, SNAPSHOTS. FrameRecorder.getSupportedStrategies() returns List.of(SNAPSHOTS). HardwareConfig carries the field through ctor/toString, and the frontend renders a one-item dropdown for it. Pure scaffolding for a hypothetical second strategy; can be reintroduced when that strategy actually lands.

Both buckets compile and ship cleanly on their own — happy to land them, drop them, or treat them as a separate follow-up.

@JosephTLockwood

Copy link
Copy Markdown
Contributor Author

At this point I think the flow is solid and everything is working. I was able to Record a Video with no Apriltag Detection enabled. Run it through repaly and get poses. Then use the replay file AKit replay to correct robot pose. There are a ton of decisions to be made still on what to log, not log, file types, etc... I struggled with it and went through a ton of diffrent version (as you can probably seem from git history) but think it's at least good enough for testing.

If you have a chance to test let me know how it goes. Replay is inside of Settings for now. Only thing I want to do is see if I can simplify anything more. Still not familar with PV library. Probably duplicated stuff.

Quick Rundown on what is being logged in replay file
image

Here is the file I use for the IO implementation

VisionIOPhotonVisionJSONSimple.java

Remove the robot-triggered "free disk space before a match" path:
PhotonUtils.reserveSpace, PhotonCamera.willRecord,
HardwareManager.reserveRecordingSpace, VisionModule.recordingSpaceNeeded,
and the NT reserveRecordingSpace subscribers.

It was non-functional end to end: the lib published to a root-level
topic the server never watched, the server subscribed to one topic
name as two conflicting NT types, a usable-space read of -1 would
delete every recording, and the match-data comparator could throw on
the NT listener thread. Replay does not depend on any of it.
Remove the single-value RecordingStrategy enum and everything wired to
it: the FrameRecorder constructor switch, getSupportedStrategies,
HardwareConfig.recordingStrategy, HardwareManager.getRecordingStrategy,
the UI settings fields, and the client's Recording Strategy select.

The enum had exactly one value (SNAPSHOTS) and nothing branched on it;
the client's setRecordingStrategy sent a websocket key the server had
no handler for. It was pure future-proofing with no behavior behind it.
The writer loop caught only InterruptedException, so an unchecked
exception from imwrite (e.g. an empty Mat) silently killed the thread;
release() then did a blocking put() of the poison pill, hanging the
calling NT listener thread forever on a full queue.

Catch unchecked exceptions in the loop body (stop recording, keep
draining), reject empty mats in recordFrame before they reach imwrite,
and in release() use a timed offer for the poison pill with an
interrupt-and-join escalation. Only drain the queue and release the
native jpegWriteParams once the writer is confirmed dead, so we never
free a MatOfInt still in use inside imwrite.
Two recordings resolving to the same directory within a match would
truncate metadata.jsonl while the previous run's frames/*.jpg persisted,
so replay silently served stale frames for any dropped sequence.
uniquifyOutputPath now suffixes the directory (_2, _3, ...) when the
target already holds a recording.

Name recording directories timestamp-first with a sanitized match-data
token appended, instead of the raw match-data string. The raw string's
colons made safeResolve reject the path on Windows, and reused names
collided within a match.
Delete the temp zip if ZipUtil.pack throws (it was created just before
the pack call and leaked on failure), and make exportAll fail with a
clear "No recordings to export" when the recordings root is missing or
empty instead of returning a zero-byte, invalid zip. Collapse the
duplicated export/exportCamera bodies into a single zipDirectory helper.
startRecording/stopRecording did a get-then-act on an AtomicBoolean and
returned a boolean no caller read; reduce them to a plain set. Also drop
the leftover second jpegWriteParams.release() in release() now that the
native params are freed exactly once, on the writer-confirmed-dead path.
The constructor reaches updateEntries twice (once via updateCameraNickname),
and this PR made updateEntries non-idempotent by adding the four replay
publishers, so the first set leaked. Close the existing publishers
null-safely before recreating them, which also covers later nickname
changes.
setRecording/getRecording were reimplemented as identical
"must not throw on the NT listener thread" no-ops in FileFrameProvider,
LibcameraGpuFrameProvider and FileLogFrameProvider, and TestSource
actually violated that contract by throwing UnsupportedOperationException.
Make FrameProvider provide the warn-and-ignore no-op (getRecording
returns false) and delete the four overrides; USBFrameProvider keeps its
real implementation. Also drop the tautological
supplierBoundToTheGetterTracksTheSwap test.
pace() parked for the full recorded inter-frame delta with no way to be
woken, so requestStop() (cancel/EOF) had to wait out the gap — unbounded
on a corrupt sidecar — and VisionModule's 2s swap-back fence routinely
timed out and released the provider under a live thread. Record the
paced thread and unpark it from requestStop(), looping the park to
re-check the deadline and stop flag (parkNanos may return spuriously).
The stopped state had a standalone fallback that parked the vision
thread until interrupted when no onEof callback was wired. Every
production path (VisionModule.startReplay) wires the callback before
installing the provider, so an unwired provider is a bug; throw
IllegalStateException rather than silently starve the vision thread.
Update the EOF/cancel test coverage accordingly.
The recording get/set callbacks were bound method references
(getFrameProvider()::getRecording), so they captured the construction-time
provider and never saw the in-place swap to the replay provider. A
recording toggle during replay then armed a recorder on the parked live
camera. Use lambdas that re-read getFrameProvider() per call so the
callbacks follow the live → replay → live swap.
The exporter was created lazily on the first result with the then-current
settings hash and kept until replay end, so switching pipelines mid-replay
kept appending the new pipeline's results into the previous hash's file,
under the previous header. Track the hash the exporter was opened with and,
when it changes, close and recreate against the new settings' file. All on
the vision thread, so no new locking.
finishReplay flipped isReplaying=false at the top, before the swap-back
fence and before closing the JSON exporter, so a status poll could observe
"ended" and download a truncated results file. Reorder so the exporter is
closed and nulled before isReplaying flips and before publishReplayState.
Make jsonExporterDisabled volatile (written on the vision thread, reset by
the replay worker), snapshot the exporter in stop() before closing, and
guard finishReplay against a double swap-back on a null provider.
The embedded packet's publish timestamp mixed clocks: capture came from
the recorded capture_ns (shifted into the TSS base) while publish came
from the replay run's NetworkTablesJNI.now(), so publish - capture across
the two epochs was garbage for any latency consumer. Derive publish as
shifted-capture + the result's own processing latency, so the exporter
reads no wall clock and is deterministic. Drop the now-unused clock
injection constructor and update the exporter tests to pin the invariant.
startsWith accepted equality, so a terminal ".." or "." segment resolved
to the root (or a camera dir) itself and passed — a delete request with
recordings=[".."] could wipe the whole recordings tree. Reject any
segment that is ".", "..", absolute/drive-relative, or contains a path
separator, and reject a resolved path equal to the root. Replace the
assertion-free illegal-char test with real assertThrows coverage plus
cases for the new rejections.
exportCamera and exportAll wrapped ctx.result(InputStream) in
try-with-resources and deleted the temp zip in a finally, but Javalin
writes the stream asynchronously, so the stream was closed and the file
deleted before Jetty sent the body ("Stream Closed" client-side) — the
exact bug the individual handler documented and worked around by buffering
the whole zip into heap (an OOM risk on coprocessors). Route all three
through one sendZipDownload helper that opens the stream DELETE_ON_CLOSE
and lets Jetty close it. Return 404 (not 500) when a camera has no
recordings.
Return 400 when the delete request omits the recordings key (it NPE'd
into a 500), report deleted names with String.join instead of a raw
String[].toString(), and republish full settings after delete and nuke
so the client's recordings list refreshes instead of listing entries
that no longer exist. Also sanitize the results-download filename as a
single unit so a name with a quote can't break the Content-Disposition
header.
A merge dropped fpsLimitPublisher.close(), inputSaveImgEntry.close(), and
outputSaveImgEntry.close() from PhotonCamera.close() while the fields
stayed live, leaking those NT publishers past close(). Restore them and
remove the duplicate pipelineIndexRequest.close() while here.
Match the class's own getDriverMode precedent and the C++/Python bindings
(GetRecording / getRecording). New API in this PR, so nothing external
depends on the old name.
The recordingEntry BooleanTopic was declared in the Java and C++
NTTopicSet but never assigned or read anywhere. Remove it, and change the
recordingRequest Subscribe(0) to Subscribe(false) in the C++ header to
match Java/Python and the adjacent SetDefault(false).
…uard

pv-delete-modal exposes onBackup, not backup, so the nuke dialog's
"Backup Data" button never rendered — bind :on-backup. Move the
selection-seeding side effect out of the camerasWithRecordings computed
into an immediate watch so the computed stays pure. Guard the
auto-download watch with a per-session key so one replay end can't
download its results twice.
Each useReplayStatus() caller opened its own 250ms poll and held its own
state, so the settings page double-polled and state died on unmount. Hoist
the active ref, fetch, and interval to module scope with a subscriber
refcount: polling starts on the first subscriber and stops on the last.
Add a staleness cap so a crashed backend clears the replay banner after a
run of failed polls instead of pinning it forever.
Bring the record + replay branch current with main. Notable upstream
changes integrated:

- JDK 25 build toolchain.
- Jackson -> Avaje Jsonb migration (PhotonVision#2503): JacksonUtils removed. Ported
  RequestHandler's recording/replay endpoints to Jsonb.instance().type()
  with @JSON request records and a @JSON ResultHeader for the results
  listing; JsonProcessingException -> io.avaje.json.JsonException.
- Per-camera isEnabled/disabled NT topics (PhotonVision#2484, PhotonVision#2499): merged the new
  enabled supplier/consumer + isEnabledListener alongside the replay
  publishers in NTDataPublisher, and the enabled subscriber/publisher in
  PhotonCamera (Java + C++), keeping recording/replay wiring intact.
- Rubik -> generic TFLite OD refactor (PhotonVision#2516): RequestHandler import.

Conflicts resolved in NTDataPublisher, VisionModule, VisionRunner,
RequestHandler, and PhotonCamera (.java/.cpp/.h). spotless, JDK 25
compile across all modules, and the replay JUnit suite are green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Things relating to photon-core and photon-server frontend Having to do with PhotonClient and its related items photonlib Things related to the PhotonVision library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants