lesavka: add eye decoder comparison probe

This commit is contained in:
Brad Stein 2026-04-18 11:27:46 -03:00
parent e3cb555c90
commit 377cda1309
2 changed files with 188 additions and 0 deletions

View File

@ -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"

View File

@ -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<u8> {
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<u8>)> {
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::<gst::Pipeline>()
.expect("pipeline");
let appsrc = pipeline
.by_name("src")
.expect("appsrc")
.downcast::<gst_app::AppSrc>()
.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::<gst_app::AppSink>()
.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::<i32>("width")
.map_err(|err| anyhow::anyhow!("missing output width: {err}"))?;
let out_height = structure
.get::<i32>("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:?}"
);
}
}