Skip to content

[Bug]: rgb_frames(frames_per_second=…) samples come from after the message mediapipe_hands labels them with, and repeat across a lost keyframe #733

Description

@xb-gjf

Version or commit

hflow 0.2.21 (PyPI) and main at 6cf6e98: the script prints the same output on both.

Environment

macOS 26.6.2 (arm64), Python 3.11.16, PyAV 18.1.0, ffmpeg 8.1.1 (Homebrew); the uv sync --locked environment plus the packages in step 1

Minimal reproduction

Follow-up to #731, as asked there. Same synthetic data: one 640×360 H.264 camera at 30 fps, a keyframe every 10
frames, 300 frames, each picture drawing its source frame number as 10 black/white blocks. One raw MCAP is complete,
the other lacks source frame 100 (an IDR). Both go through the default TransformConfig().

The script samples with rgb_frames and labels the samples exactly as mediapipe_hands does
(mediapipe_hands.py:457-515): source_fps is one over the median log-time interval, and
source_log_times_for_sampled_frames maps sample i to a source message. It then reads the frame number from each
RGB array. MediaPipe itself is not needed: the question is only which picture each label is attached to.

  1. pip install hflow av pillow mcap-protobuf-support foxglove-schemas-protobuf (and ffmpeg 5.1 or newer on PATH, as HFlow needs)
  2. python hflow_rgb_frames_repro.py
hflow_rgb_frames_repro.py
"""rgb_frames(frames_per_second=...): is each sampled picture the source frame its label names?

Labels are made the way mediapipe_hands makes them (mediapipe_hands.py:457-515): source_fps is one over the median
log-time interval, and source_log_times_for_sampled_frames maps sample i to a source message. MediaPipe itself is
not needed: the question is only which picture each label is attached to.

Synthetic data only. Each frame draws its source frame number as 10 black/white blocks, so the number can be read
back from the RGB array. One raw MCAP is complete, the other lacks source frame 100 (an IDR).

    pip install hflow av pillow mcap-protobuf-support foxglove-schemas-protobuf
    python hflow_rgb_frames_repro.py
"""
import tempfile
from fractions import Fraction
from pathlib import Path

import av
import numpy as np
from foxglove_schemas_protobuf.CompressedVideo_pb2 import CompressedVideo
from mcap.writer import CompressionType
from mcap_protobuf.writer import Writer

from hflow._video_measurement_toolchain import resolved_video_measurement_toolchain
from hflow._video_measurements._raw_frames import rgb_frames
from hflow.episode import Episode
from hflow.transform import TransformConfig, write_canonical_episode
from hflow.video import source_log_times_for_sampled_frames

W, H, FPS, N, GOP, BITS = 640, 360, 30, 300, 10, 10
T0 = 1_760_000_000_000_000_000
TOPIC = "/camera/cam0/video"


def draw(i):
    y = np.full((H, W), 128, np.uint8)
    y[:40] = 16
    for b in range(BITS):
        if (i >> (BITS - 1 - b)) & 1:
            y[:40, b * (W // BITS):(b + 1) * (W // BITS)] = 235
    return np.stack([y, y, y], -1)


def read_number(rgb):
    luma = rgb[:40].astype(np.float32).mean(axis=2)
    bw = W // BITS
    return int("".join(str(int(luma[:, b * bw + 8:(b + 1) * bw - 8].mean() > 128)) for b in range(BITS)), 2)


def encode():
    ctx = av.CodecContext.create("libx264", "w")
    ctx.width, ctx.height, ctx.pix_fmt = W, H, "yuv420p"
    ctx.framerate, ctx.time_base = FPS, Fraction(1, FPS)
    ctx.gop_size, ctx.max_b_frames = GOP, 0
    ctx.options = {"profile": "baseline", "tune": "zerolatency", "keyint_min": str(GOP), "sc_threshold": "0"}
    packets = []
    for i in range(N):
        frame = av.VideoFrame.from_ndarray(draw(i), format="rgb24").reformat(format="yuv420p")
        frame.pts = i
        packets += [bytes(p) for p in ctx.encode(frame)]
    return packets + [bytes(p) for p in ctx.encode(None)]


def write_raw(path, packets, drop):
    with open(path, "wb") as f:
        w = Writer(f, compression=CompressionType.ZSTD)
        for i in range(N):
            if i == drop:
                continue
            t = T0 + i * 1_000_000_000 // FPS
            msg = CompressedVideo(frame_id="cam0", data=packets[i], format="h264")
            msg.timestamp.FromNanoseconds(t)
            w.write_message(TOPIC, msg, log_time=t, publish_time=t)
        w.finish()


def source_frame_at(log_time_ns):
    return round((log_time_ns - T0) * FPS / 1e9)


def sample(ep, fps):
    """(source frame each label names, source frame each picture shows), as mediapipe_hands would label them."""
    stamps_ns = ep.channel(TOPIC).timestamps
    median_interval_s = float(np.median(np.diff(stamps_ns)) / 1e9)
    with rgb_frames(ep.video(TOPIC), toolchain=resolved_video_measurement_toolchain(),
                    frames_per_second=fps) as frames:
        shown = [read_number(f) for f in frames]
    labels = source_log_times_for_sampled_frames(stamps_ns.tolist(), source_fps=1.0 / median_interval_s,
                                                 sample_fps=fps, start_s=0.0, frame_count=len(shown))
    return list(zip([source_frame_at(t) for t in labels], shown))


def main():
    packets = encode()
    with tempfile.TemporaryDirectory() as work:
        for drop in (None, 100):
            raw, canonical = Path(work, f"raw_{drop}.mcap"), Path(work, f"canonical_{drop}.mcap")
            write_raw(raw, packets, drop)
            write_canonical_episode(str(raw), str(canonical), TransformConfig())
            print(f"\nsource {'complete' if drop is None else f'missing frame {drop} (an IDR)'}")
            ep = Episode(str(canonical), workdir=Path(work, f"ep_{drop}"))
            for fps in (30, 10, 3, 1):
                pairs = sample(ep, fps)
                late = sorted({shown - named for named, shown in pairs})
                print(f"  rgb_frames(frames_per_second={fps:2d}): {len(pairs):3d} frames; picture minus the frame "
                      f"its label names: {late}; first pairs (named, shown): {pairs[:3]}")
                if drop is not None:
                    gap = [p for p in pairs if drop - 1 <= p[0] <= drop + 10]
                    print(f"      samples named from {drop - 1} to {drop + 10}: {gap}")


if __name__ == "__main__":
    main()

Expected behavior

mediapipe_hands labels each sampled frame with a source message, and builds the no_hand_detected intervals from
those log times. source_log_times_for_sampled_frames does that on the stated assumption that the fps filter
"emits the frame VISIBLE at each output tick (the last frame at-or-before it)". So the picture behind sample i
should be the message its label names. A sample whose message the decoder could not decode should not be passed on
as some other message's picture.

Actual behavior

Output on main (6cf6e98), trimmed; "(named, shown)" pairs are the source frame the label names and the one the
RGB array shows:

source complete
  rgb_frames(frames_per_second=30): 300 frames; picture minus the frame its label names: [0]
  rgb_frames(frames_per_second=10): 100 frames; picture minus the frame its label names: [1]
  rgb_frames(frames_per_second= 3):  30 frames; picture minus the frame its label names: [4]
  rgb_frames(frames_per_second= 1):  10 frames; picture minus the frame its label names: [14]; first pairs (named, shown): [(0, 14), (30, 44), (60, 74)]

source missing frame 100 (an IDR)
  rgb_frames(frames_per_second=30): 299 frames
      samples named from 99 to 110: [(99, 99), (101, 99), (102, 99), (103, 99), (104, 99), (105, 99), (106, 99), (107, 99), (108, 99), (109, 111), (110, 110)]
  rgb_frames(frames_per_second= 1):  10 frames; picture minus the frame its label names: [9, 14]

The same two problems as frames() in #731:

  1. The picture comes from after its label. rgb_frames uses the fps filter with its default round=near, so
    the offset is the same as in [Bug]: frames(fps=…) can return pictures from after their sample time, and repeats the last picture across a lost keyframe #731: just under half the ratio of source rate to sample rate, in source frames.
    At mediapipe_hands' default sample_fps=1.0 on a 30 fps camera, every picture the model sees is 14 frames
    (0.47 s) after the log time it is labelled with, so each no_hand_detected interval is placed about that much
    before the pictures it was measured on. The shares are counts over the samples, so this part only moves the
    intervals: the samples are still evenly spaced, just half a sample interval later.
  2. Across a lost keyframe, the last good picture is repeated under the log times of messages that were not
    decoded (frame 99 above), so the model's result on frame 99 is counted again for each of them, in the shares as
    well as the intervals. This also happens
    at the default 1 fps: the sample labelled 90 shows frame 99, where the other samples are 14 frames late. The pair
    (109, 111) is not frame 111: its last block reads 255, while at 30 fps every block of every other sample reads
    exactly 16 or 235. Frame 109 depends on the lost keyframe; the decoder output a concealed picture for it instead
    of dropping it, the case [Bug]: frames_at_indices returns frames shifted by several positions, without an error, after a pass-through H.264 camera loses a keyframe #727 stays open for.

Additional context

With the filter changed to fps=…:round=up (a separate run that patched the filter graph rgb_frames passes to
ffmpeg; the script above does not include it), every picture on the complete source matches its label at 1, 3, 10,
15 and 30 fps. Across the lost keyframe the repeated frame 99 and the concealed picture remain. Leaving out the
repeats needs to know which pictures the decoder produced, as #732 proposes for frames(); rgb_frames yields bare
arrays, so its sample numbers would also have to be passed along for the labels not to shift. The concealed picture
is one the decoder did produce, so that does not remove it; that is the #727 case.

Tested with ffmpeg 8.1.1 only, on synthetic data. I have not run MediaPipe itself.

Activity

  1. kstonekuan commented on Oct 28, 2026

    @kstonekuan
    Contributor

    #734 fixes the sampling offset. Keeping this open for the last good picture repeated across a lost keyframe.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions