From 377cda130972eb698c69e1c43d7b32e12dd62c91 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 18 Apr 2026 11:27:46 -0300 Subject: [PATCH] lesavka: add eye decoder comparison probe --- scripts/manual/compare-eye-decoders.sh | 38 +++++++ server/src/video.rs | 150 +++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100755 scripts/manual/compare-eye-decoders.sh diff --git a/scripts/manual/compare-eye-decoders.sh b/scripts/manual/compare-eye-decoders.sh new file mode 100755 index 0000000..299ed5b --- /dev/null +++ b/scripts/manual/compare-eye-decoders.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE="${1:-/dev/lesavka_r_eye}" +OUTDIR="${2:-/tmp/lesavka-decoder-probe}" +WAIT_SECONDS="${LESAVKA_DECODER_PROBE_WAIT_SECONDS:-3600}" +BUFFERS="${LESAVKA_DECODER_PROBE_BUFFERS:-120}" +POLL_SECONDS="${LESAVKA_DECODER_PROBE_POLL_SECONDS:-1}" + +mkdir -p "$OUTDIR" + +deadline=$((SECONDS + WAIT_SECONDS)) +while [[ ! -e "$DEVICE" ]]; do + if (( SECONDS >= deadline )); then + echo "decoder probe timed out waiting for $DEVICE" >&2 + exit 124 + fi + sleep "$POLL_SECONDS" +done + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +prefix="$OUTDIR/${timestamp}-$(basename "$DEVICE")" + +echo "capturing decoder comparison for $DEVICE into $prefix.*" + +gst-launch-1.0 -e \ + v4l2src device="$DEVICE" io-mode=mmap do-timestamp=true num-buffers="$BUFFERS" ! \ + queue max-size-buffers=8 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + h264parse disable-passthrough=true config-interval=-1 ! \ + video/x-h264,stream-format=byte-stream,alignment=au ! \ + tee name=t \ + t. ! queue ! filesink location="${prefix}-source.h264" \ + t. ! queue ! avdec_h264 ! videoconvert ! video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ + pngenc snapshot=true ! filesink location="${prefix}-avdec.png" \ + t. ! queue ! openh264dec ! videoconvert ! video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ + pngenc snapshot=true ! filesink location="${prefix}-openh264.png" + +echo "decoder comparison artifacts written under $OUTDIR" diff --git a/server/src/video.rs b/server/src/video.rs index 901bc89..0ac72aa 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -714,6 +714,7 @@ pub async fn eye_ball_with_request( #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] fn source_profile_stays_pass_through_without_explicit_reencode_request() { @@ -743,4 +744,153 @@ mod tests { assert!(bitrate_request.reencode); assert!(fps_request.reencode); } + + fn marker_frame(width: i32, height: i32) -> Vec { + let mut rgba = vec![0_u8; (width * height * 4) as usize]; + let marker = 96; + for y in 0..height { + for x in 0..width { + let idx = ((y * width + x) * 4) as usize; + let (r, g, b) = if x < marker && y < marker { + (255, 0, 0) + } else if x >= width - marker && y < marker { + (0, 255, 0) + } else if x < marker && y >= height - marker { + (0, 0, 255) + } else if x >= width - marker && y >= height - marker { + (255, 255, 0) + } else { + (24, 24, 24) + }; + rgba[idx..idx + 4].copy_from_slice(&[r, g, b, 255]); + } + } + rgba + } + + fn pull_reencoded_frame_rgba( + width: i32, + height: i32, + input_fps: u32, + output_fps: u32, + ) -> anyhow::Result<(i32, i32, Vec)> { + gst::init().context("gst init")?; + let desc = format!( + "appsrc name=src is-live=false format=time block=true ! \ + videoconvert ! video/x-raw,format=I420,width={width},height={height},framerate={input_fps}/1,pixel-aspect-ratio=1/1 ! \ + x264enc tune=zerolatency speed-preset=veryfast bitrate=12000 key-int-max={input_fps} option-string=sar=1/1 ! \ + h264parse disable-passthrough=true config-interval=-1 ! \ + avdec_h264 ! videoconvert ! videoscale add-borders=false ! videorate ! \ + video/x-raw,format=I420,width={width},height={height},framerate={output_fps}/1,pixel-aspect-ratio=1/1 ! \ + x264enc tune=zerolatency speed-preset=faster bitrate=12000 key-int-max=5 option-string=sar=1/1 ! \ + h264parse disable-passthrough=true config-interval=-1 ! \ + avdec_h264 ! videoconvert ! video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ + appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true" + ); + let pipeline = gst::parse::launch(&desc)? + .downcast::() + .expect("pipeline"); + let appsrc = pipeline + .by_name("src") + .expect("appsrc") + .downcast::() + .expect("appsrc cast"); + appsrc.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", &"RGBA") + .field("width", &width) + .field("height", &height) + .field("framerate", &gst::Fraction::new(input_fps as i32, 1)) + .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1)) + .build(), + )); + appsrc.set_format(gst::Format::Time); + let appsink = pipeline + .by_name("sink") + .expect("appsink") + .downcast::() + .expect("appsink cast"); + appsink.set_caps(Some( + &gst::Caps::builder("video/x-raw") + .field("format", &"RGBA") + .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1)) + .build(), + )); + + pipeline + .set_state(gst::State::Playing) + .context("starting reencode probe pipeline")?; + + let mut buffer = gst::Buffer::from_mut_slice(marker_frame(width, height)); + if let Some(buf) = buffer.get_mut() { + buf.set_pts(Some(gst::ClockTime::ZERO)); + buf.set_duration(Some( + gst::ClockTime::from_nseconds(1_000_000_000_u64 / input_fps.max(1) as u64), + )); + } + appsrc + .push_buffer(buffer) + .map_err(|err| anyhow::anyhow!("push buffer failed: {err:?}"))?; + appsrc + .end_of_stream() + .map_err(|err| anyhow::anyhow!("eos failed: {err:?}"))?; + + let sample = appsink + .try_pull_sample(gst::ClockTime::from_seconds(5)) + .ok_or_else(|| anyhow::anyhow!("timed out pulling reencoded frame"))?; + let caps = sample.caps().ok_or_else(|| anyhow::anyhow!("missing sample caps"))?; + let structure = caps + .structure(0) + .ok_or_else(|| anyhow::anyhow!("missing caps structure"))?; + let out_width = structure + .get::("width") + .map_err(|err| anyhow::anyhow!("missing output width: {err}"))?; + let out_height = structure + .get::("height") + .map_err(|err| anyhow::anyhow!("missing output height: {err}"))?; + let buffer = sample + .buffer() + .ok_or_else(|| anyhow::anyhow!("missing sample buffer"))?; + let map = buffer + .map_readable() + .map_err(|_| anyhow::anyhow!("sample map failed"))?; + let rgba = map.as_slice().to_vec(); + let _ = pipeline.set_state(gst::State::Null); + Ok((out_width, out_height, rgba)) + } + + fn rgba_pixel(rgba: &[u8], width: i32, x: i32, y: i32) -> (u8, u8, u8) { + let idx = ((y * width + x) * 4) as usize; + (rgba[idx], rgba[idx + 1], rgba[idx + 2]) + } + + #[test] + #[serial] + fn reencode_probe_preserves_corner_markers_on_full_frame_content() { + let (width, height, rgba) = + pull_reencoded_frame_rgba(1920, 1080, 60, 30).expect("probe frame"); + assert_eq!((width, height), (1920, 1080)); + + let top_left = rgba_pixel(&rgba, width, 24, 24); + let top_right = rgba_pixel(&rgba, width, width - 25, 24); + let bottom_left = rgba_pixel(&rgba, width, 24, height - 25); + let bottom_right = rgba_pixel(&rgba, width, width - 25, height - 25); + + assert!( + top_left.0 > 180 && top_left.1 < 80 && top_left.2 < 80, + "top-left marker drifted: {top_left:?}" + ); + assert!( + top_right.1 > 180 && top_right.0 < 80 && top_right.2 < 80, + "top-right marker drifted: {top_right:?}" + ); + assert!( + bottom_left.2 > 180 && bottom_left.0 < 80 && bottom_left.1 < 80, + "bottom-left marker drifted: {bottom_left:?}" + ); + assert!( + bottom_right.0 > 180 && bottom_right.1 > 180 && bottom_right.2 < 120, + "bottom-right marker drifted: {bottom_right:?}" + ); + } }