Repository navigation
Description
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.
pip install hflow av pillow mcap-protobuf-support foxglove-schemas-protobuf(and ffmpeg 5.1 or newer onPATH, as HFlow needs)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:
- The picture comes from after its label.
rgb_framesuses the fps filter with its defaultround=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.
Atmediapipe_hands' defaultsample_fps=1.0on a 30 fps camera, every picture the model sees is 14 frames
(0.47 s) after the log time it is labelled with, so eachno_hand_detectedinterval 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. - 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_indicesreturns 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
test(ffmpeg): verify expected frame count and non-dividing rate in rg…
fix(measurements): align rgb_frames sampling with output ticks (#733) (…
kstonekuan commentedon Oct 28, 2026
#734 fixes the sampling offset. Keeping this open for the last good picture repeated across a lost keyframe.