Skip to content

fix(episode): select frames at indices by time, not the decoded counter - #730

Open
ayam04 wants to merge 3 commits into
Hebbian-Robotics:mainfrom
ayam04:fix/frames-at-indices-time
Open

ayam04 wants to merge 3 commits into
Hebbian-Robotics:mainfrom
ayam04:fix/frames-at-indices-time

Conversation

@ayam04

@ayam04 ayam04 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What changed

frames_at_indices selected pictures with ffmpeg's select on the decoded-picture counter n, which assumes decoded picture n is message n. On a pass-through camera that loses a keyframe the decoder drops the pictures that depend on it (and conceals one), so every index after the gap returned a picture shifted by the shortfall while still reporting the requested message's log_time_ns.

The selection now keys on time: the MP4 is constant rate, so message k is the picture nearest k / fps, and each requested index (or run of consecutive indices) compiles to between(t,(start-0.5)/fps,(end+0.5)/fps). A picture the decoder never produced matches nothing, so the existing "ffmpeg extracted X of Y selected frames" check raises instead of handing back a shifted frame, which is the contract #727's Expected behavior asks for.

Validation

RED before the change, GREEN after, on the regression added here:

tests/test_end_to_end.py::test_frames_at_indices_pairs_pictures_by_time_when_a_keyframe_is_missing
before (6cf6e98): assert [50, 59] == [42, 51]   # asked for messages 41 and 50, got pictures 8 frames late
after:            passed

The regression pins content, not counts: each source picture carries its own frame number as ten black/white blocks and the test reads that number back out of the returned JPEG. It also requests frames inside the gap, which now raises.

Gate (WSL, the worktree's uv sync --locked --all-extras environment):

uv run --no-sync pytest tests/test_episode.py tests/test_end_to_end.py -q  -> 51 passed
uv run --no-sync ruff check src/hflow/episode.py tests/test_end_to_end.py  -> All checks passed
uv run --no-sync ruff format --check src/hflow/episode.py tests/test_end_to_end.py -> 2 files already formatted
uv run --no-sync ty check                                                  -> All checks passed

Notes

  • Credit for the diagnosis and the direction: @xb-gjf's report identified the counter-vs-message mismatch and validated timestamp-based selection on a damaged stream. If you would rather they carry it, close this and I will hand over the diff.
  • Out of scope here, from the other options in [Bug]: frames_at_indices returns frames shifted by several positions, without an error, after a pass-through H.264 camera loses a keyframe #727: refusing or quarantining a channel with a lost reference at transform time, and catching the single concealed picture that a gap can still admit (that needs H.264 frame_num or the keyframe-cadence check).
  • The 20,000-index sparse-selection test now passes the camera's fps to _frame_selection_expression, since the expression is built from timestamps.

Refs #727


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

ffmpeg's `select` on `n` assumes decoded picture *n* is message *n*. When a
pass-through camera loses a keyframe, the decoder drops the pictures that
depend on it (and conceals one), so every later index returned a picture
shifted by the shortfall while keeping the requested message's log time: the
regression below asked for messages 41 and 50 of a 60-frame stream that lost
source frame 30 and got pictures 50 and 59 back, with no error.

Each requested index now selects the picture nearest k/fps -- the MP4 is
constant rate, so message k is the picture at that time. A picture the decoder
never produced matches nothing and the existing extracted-count check reports
the request instead of returning a shifted frame, which is the contract the
issue asks for.

The regression encodes the source frame number into each picture as ten
black/white blocks, so the check compares the picture inside the returned JPEG
against the message that was asked for. RED on 6cf6e98: [50, 59] instead of
[42, 51]; GREEN after the change, including a request inside the gap that now
raises.

Refs Hebbian-Robotics#727
@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no blocking issues remain.

Summary

frames_at_indices now selects pictures by time instead of the decoded-picture count.

  • Camera frame requests now match pictures by time.

Reviews (3) · Last reviewed commit: "fix(episode): retire frame caches writte..." · Reviewed by Greptile

Comment thread src/hflow/episode.py Outdated
`%g` prints six significant digits, so index 250001 at 25 fps compiled to
`between(t,10000,10000.1)` -- a window wide enough to also match indices
250000 and 250002, which reintroduced the shifted-picture bug this branch
fixes once the selection lands past six-digit timestamps. Format both
boundaries with round-trip precision and assert a large-index window stays
exactly one frame wide.

Signed-off-by: Ayam <ayamullahkhan04@gmail.com>
greptile-apps[bot]
greptile-apps Bot previously approved these changes Oct 20, 2026

@kstonekuan kstonekuan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you invalidate the old indexed-frame cache and add upgrade coverage? With the regression's damaged stream and indices [41, 50], I populated a workdir using main's counter selection, then reopened it on this head: it still returns pictures [50, 59]; a fresh workdir returns the correct [42, 51].

Please also change Fixes #727 to Refs #727: #731 tracks sampled frames, but the concealed-picture case from #727 still needs to stay open.

The frame cache directory is its own key, so the selection change from Hebbian-Robotics#727 left every
workdir populated before it holding pictures that are shifted after a lost keyframe: reopening
the same workdir returned the counter selection's output with no error, which is what a
reviewer reproduced on the regression's damaged stream (indices [41, 50] returned pictures
[50, 59] from a workdir built on main, [42, 51] from a fresh one).

The directory name now carries a layout tag, `v2-time`, so a cache written by the counter
selection is never read again. The tag is a constant rather than part of the digest because
the cache key has to change when the selection semantics change, not when the indices do.

The new regression recreates exactly that upgrade: it writes the pictures main produced --
frame numbers 50 and 59 -- into the directory main named, under the workdir the episode opens,
and then reads the same indices from that workdir. Without the tag the call returns [50, 59];
with it, [42, 51], and every returned path is outside the retired directory.

- `pytest tests/test_episode.py tests/test_end_to_end.py -q` -- 53 passed.
- RED with the tag removed: `assert [50, 59] == [42, 51]`, the reported symptom.
- `ruff check`, `ruff format --check`, `ty check` -- clean.

Refs Hebbian-Robotics#727

Signed-off-by: Ayam <ayamullahkhan04@gmail.com>
@greptile-apps
greptile-apps Bot dismissed their stale review October 24, 2026 02:49

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@ayam04

ayam04 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Both done in 35d358a.

Cache invalidation. You were right that the stale workdir was the whole risk: the cache directory name is the key, and the selection change left it unchanged, so a workdir built on main kept serving the counter selection's pictures. The name now carries a layout tag:

_FRAMES_AT_INDICES_CACHE_LAYOUT = "v2-time"

f"frames_{_sanitize_topic(topic)}_indices_{selection_digest}_"
f"count_{len(selected_frame_indices)}_{_FRAMES_AT_INDICES_CACHE_LAYOUT}"

It is a constant rather than part of the digest because the key has to change when the selection semantics change, not when the indices do. Every directory written before this change is retired by name; nothing is deleted, so a concurrent reader of an old workdir is never raced or surprised mid-read.

Upgrade coverage. The new regression builds your scenario directly: it takes the damaged stream from #727, writes the pictures main produced for indices [41, 50] -- frame numbers 50 and 59, read back out of the JPEGs -- into the directory main named, under the workdir the episode then opens, and asks the same episode for the same indices.

  • without the tag: assert [50, 59] == [42, 51], exactly what you saw;
  • with it: [42, 51], and every returned path is outside the retired directory, so the check is about provenance as well as content.

Fixes -> Refs. The PR body and both commit messages already say Refs #727; #731 keeps the sampled-frames case open, and the concealed-picture case from #727 stays with it.

Gate on this head: pytest tests/test_episode.py tests/test_end_to_end.py -q -> 53 passed; ruff check, ruff format --check, ty check -> clean. Branch is MERGEABLE/CLEAN.

@greptile-apps

greptile-apps Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants