fix(sync): harden probe truth path

This commit is contained in:
Brad Stein 2026-04-27 13:35:18 -03:00
parent 67f4cd156e
commit f477332834
11 changed files with 144 additions and 59 deletions

6
Cargo.lock generated
View File

@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.14.18" version = "0.14.19"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1676,7 +1676,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.14.18" version = "0.14.19"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1688,7 +1688,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.14.18" version = "0.14.19"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.14.18" version = "0.14.19"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -142,27 +142,8 @@ pub(super) fn run_command(command: &mut Command, description: &str) -> Result<Ve
} }
fn summarize_frame_brightness(frame: &[u8]) -> u8 { fn summarize_frame_brightness(frame: &[u8]) -> u8 {
let crop_start = VIDEO_ANALYSIS_SIDE_PX / 4; let mean = frame.iter().map(|value| u64::from(*value)).sum::<u64>() / frame.len().max(1) as u64;
let crop_end = VIDEO_ANALYSIS_SIDE_PX - crop_start; mean.min(u64::from(u8::MAX)) as u8
let mut center_total = 0u64;
let mut center_pixels = 0u64;
let mut border_total = 0u64;
let mut border_pixels = 0u64;
for y in 0..VIDEO_ANALYSIS_SIDE_PX {
for x in 0..VIDEO_ANALYSIS_SIDE_PX {
let value = u64::from(frame[y * VIDEO_ANALYSIS_SIDE_PX + x]);
if (crop_start..crop_end).contains(&x) && (crop_start..crop_end).contains(&y) {
center_total += value;
center_pixels += 1;
} else {
border_total += value;
border_pixels += 1;
}
}
}
let center_mean = center_total / center_pixels.max(1);
let border_mean = border_total / border_pixels.max(1);
center_mean.abs_diff(border_mean).min(u64::from(u8::MAX)) as u8
} }
#[cfg(test)] #[cfg(test)]
@ -221,7 +202,7 @@ mod tests {
&[1, 0], &[1, 0],
|capture_path| { |capture_path| {
let parsed = extract_video_brightness(capture_path, 1).expect("video brightness"); let parsed = extract_video_brightness(capture_path, 1).expect("video brightness");
assert_eq!(parsed, vec![15]); assert_eq!(parsed, vec![16]);
}, },
); );
} }
@ -245,7 +226,7 @@ mod tests {
} }
#[test] #[test]
fn extract_video_brightness_uses_center_weighted_thumbnail_average() { fn extract_video_brightness_uses_full_frame_thumbnail_average() {
let brightness = vec![20u8, 45, 20]; let brightness = vec![20u8, 45, 20];
with_fake_media_tools( with_fake_media_tools(
&frame_json(&[0.0, 0.1, 0.2]), &frame_json(&[0.0, 0.1, 0.2]),
@ -253,7 +234,7 @@ mod tests {
&[1, 0], &[1, 0],
|capture_path| { |capture_path| {
let parsed = extract_video_brightness(capture_path, 3).expect("video brightness"); let parsed = extract_video_brightness(capture_path, 3).expect("video brightness");
assert_eq!(parsed, vec![0, 25, 0]); assert_eq!(parsed, vec![20, 26, 20]);
}, },
); );
} }

View File

@ -11,6 +11,8 @@ pub(super) const DEFAULT_AUDIO_SAMPLE_RATE_HZ: u32 = 48_000;
// luma swing into a narrower band, so keep this guard modest and let the // luma swing into a narrower band, so keep this guard modest and let the
// segment logic reject genuinely flat/noisy traces. // segment logic reject genuinely flat/noisy traces.
const MIN_VIDEO_CONTRAST: u8 = 4; const MIN_VIDEO_CONTRAST: u8 = 4;
const MAX_VIDEO_ACTIVE_FRAME_FRACTION: f64 = 0.35;
const MAX_VIDEO_FLICKER_SEGMENT_FRAME_MULTIPLIER: f64 = 1.5;
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct PulseSegment { pub(crate) struct PulseSegment {
@ -43,6 +45,11 @@ pub(crate) fn detect_video_segments(
} }
let threshold = ((u16::from(min) + u16::from(max)) / 2) as u8; let threshold = ((u16::from(min) + u16::from(max)) / 2) as u8;
let frame_step_s = median_frame_step_seconds(&timestamps_s[..frame_count]).max(1.0 / 120.0); let frame_step_s = median_frame_step_seconds(&timestamps_s[..frame_count]).max(1.0 / 120.0);
let active_frames = slice
.iter()
.copied()
.filter(|level| *level >= threshold)
.count();
let mut segments = Vec::new(); let mut segments = Vec::new();
let mut previous_active = false; let mut previous_active = false;
let mut segment_start = 0.0_f64; let mut segment_start = 0.0_f64;
@ -83,6 +90,15 @@ pub(crate) fn detect_video_segments(
}); });
} }
let active_fraction = active_frames as f64 / frame_count as f64;
let median_segment_duration_s = median(segments.iter().map(|segment| segment.duration_s).collect());
if active_fraction > MAX_VIDEO_ACTIVE_FRAME_FRACTION
&& median_segment_duration_s
<= frame_step_s * MAX_VIDEO_FLICKER_SEGMENT_FRAME_MULTIPLIER
{
bail!("video flash trace looks like frame-to-frame flicker, not sync pulses");
}
Ok(segments) Ok(segments)
} }

View File

@ -174,6 +174,21 @@ fn detect_video_onsets_rejects_empty_low_contrast_and_missing_edges() {
assert!(detect_video_onsets(&[0.0, 0.1, 0.2], &[255, 255, 255]).is_err()); assert!(detect_video_onsets(&[0.0, 0.1, 0.2], &[255, 255, 255]).is_err());
} }
#[test]
fn detect_video_onsets_rejects_frame_to_frame_flicker() {
let timestamps = (0..120).map(|index| index as f64 / 30.0).collect::<Vec<_>>();
let brightness = (0..120)
.map(|index| if index % 2 == 0 { 0 } else { 6 })
.collect::<Vec<_>>();
let err = detect_video_onsets(&timestamps, &brightness).expect_err("flicker should be rejected");
assert!(
err.to_string()
.contains("frame-to-frame flicker"),
"unexpected error: {err}"
);
}
#[test] #[test]
fn detect_audio_onsets_rejects_empty_invalid_and_too_quiet_inputs() { fn detect_audio_onsets_rejects_empty_invalid_and_too_quiet_inputs() {
assert!(detect_audio_onsets(&[], 48_000, 5).is_err()); assert!(detect_audio_onsets(&[], 48_000, 5).is_err());

View File

@ -66,28 +66,17 @@ const AUDIO_PULSE_AMPLITUDE: f64 = 24_000.0;
#[cfg(any(not(coverage), test))] #[cfg(any(not(coverage), test))]
fn build_dark_probe_frame(width: usize, height: usize) -> Vec<u8> { fn build_dark_probe_frame(width: usize, height: usize) -> Vec<u8> {
vec![16u8; width.saturating_mul(height).saturating_mul(3)] vec![16u8; width.saturating_mul(height)]
} }
#[cfg(any(not(coverage), test))] #[cfg(any(not(coverage), test))]
fn build_regular_probe_frame(width: usize, height: usize) -> Vec<u8> { fn build_regular_probe_frame(width: usize, height: usize) -> Vec<u8> {
let mut frame = build_dark_probe_frame(width, height); vec![240u8; width.saturating_mul(height)]
let x0 = width / 4;
let x1 = width.saturating_sub(x0);
let y0 = height / 4;
let y1 = height.saturating_sub(y0);
fill_rect(&mut frame, width, x0, y0, x1, y1, 255);
frame
} }
#[cfg(any(not(coverage), test))] #[cfg(any(not(coverage), test))]
fn build_marker_probe_frame(width: usize, height: usize) -> Vec<u8> { fn build_marker_probe_frame(width: usize, height: usize) -> Vec<u8> {
let mut frame = build_dark_probe_frame(width, height); let mut frame = build_regular_probe_frame(width, height);
let x0 = width / 5;
let x1 = width.saturating_sub(x0);
let y0 = height / 5;
let y1 = height.saturating_sub(y0);
fill_rect(&mut frame, width, x0, y0, x1, y1, 255);
let cross_half_w = (width / 48).max(6); let cross_half_w = (width / 48).max(6);
let cross_half_h = (height / 48).max(6); let cross_half_h = (height / 48).max(6);
@ -97,19 +86,19 @@ fn build_marker_probe_frame(width: usize, height: usize) -> Vec<u8> {
&mut frame, &mut frame,
width, width,
cx.saturating_sub(cross_half_w), cx.saturating_sub(cross_half_w),
y0, 0,
(cx + cross_half_w).min(width), (cx + cross_half_w).min(width),
y1, height,
255, 16,
); );
fill_rect( fill_rect(
&mut frame, &mut frame,
width, width,
x0, 0,
cy.saturating_sub(cross_half_h), cy.saturating_sub(cross_half_h),
x1, width,
(cy + cross_half_h).min(height), (cy + cross_half_h).min(height),
255, 16,
); );
frame frame
} }
@ -124,15 +113,13 @@ fn fill_rect(
y1: usize, y1: usize,
value: u8, value: u8,
) { ) {
let height = frame.len() / width.saturating_mul(3); let height = frame.len() / width.max(1);
let x1 = x1.min(width); let x1 = x1.min(width);
let y1 = y1.min(height); let y1 = y1.min(height);
for y in y0.min(height)..y1 { for y in y0.min(height)..y1 {
for x in x0.min(width)..x1 { for x in x0.min(width)..x1 {
let offset = (y * width + x) * 3; let offset = y * width + x;
frame[offset] = value; frame[offset] = value;
frame[offset + 1] = value;
frame[offset + 2] = value;
} }
} }
} }

View File

@ -108,7 +108,7 @@ impl Drop for SyncProbeCapture {
fn build_pipeline(camera: CameraConfig, _schedule: &PulseSchedule) -> Result<gst::Pipeline> { fn build_pipeline(camera: CameraConfig, _schedule: &PulseSchedule) -> Result<gst::Pipeline> {
let video_caps = format!( let video_caps = format!(
"video/x-raw,format=RGB,width={},height={},framerate={}/1", "video/x-raw,format=GRAY8,width={},height={},framerate={}/1",
camera.width, camera.width,
camera.height, camera.height,
camera.fps.max(1) camera.fps.max(1)

View File

@ -374,3 +374,45 @@ async fn runtime_probe_audio_and_video_pts_advance_near_real_time() {
audio_span audio_span
); );
} }
#[cfg(not(coverage))]
#[tokio::test]
async fn runtime_probe_video_packets_change_across_a_pulse_boundary() {
let capture = SyncProbeCapture::new(
stub_camera(),
PulseSchedule::new(
Duration::from_secs(1),
Duration::from_secs(1),
Duration::from_millis(120),
4,
),
Duration::from_secs(2),
)
.expect("runtime capture");
let video_queue = capture.video_queue();
let mut dark_packet = None;
let mut pulse_packet = None;
loop {
let next = video_queue.pop_fresh().await;
let Some(packet) = next.packet else {
break;
};
if dark_packet.is_none() && (200_000..800_000).contains(&packet.pts) {
dark_packet = Some(packet.clone());
}
if pulse_packet.is_none() && (1_000_000..1_120_000).contains(&packet.pts) {
pulse_packet = Some(packet.clone());
}
if dark_packet.is_some() && pulse_packet.is_some() {
break;
}
}
let dark_packet = dark_packet.expect("dark packet");
let pulse_packet = pulse_packet.expect("pulse packet");
assert_ne!(dark_packet.data, pulse_packet.data);
assert!(!dark_packet.data.is_empty());
assert!(!pulse_packet.data.is_empty());
}

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.14.18" version = "0.14.19"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -122,6 +122,42 @@ resolve_pulse_source() {
' '
} }
gst_video_source_caps() {
case "${video_format}" in
""|mjpeg|MJPG)
printf 'image/jpeg,width=%s,height=%s,framerate=%s/1' \
"${resolved_video_size%x*}" \
"${resolved_video_size#*x}" \
"${video_fps}"
;;
yuyv422|YUYV|yuyv)
printf 'video/x-raw,format=YUY2,width=%s,height=%s,framerate=%s/1' \
"${resolved_video_size%x*}" \
"${resolved_video_size#*x}" \
"${video_fps}"
;;
*)
printf 'unsupported gst video_format=%s\n' "${video_format}" >&2
exit 64
;;
esac
}
gst_video_decode_chain() {
case "${video_format}" in
""|mjpeg|MJPG)
printf 'jpegdec ! '
;;
yuyv422|YUYV|yuyv)
printf ''
;;
*)
printf 'unsupported gst video_format=%s\n' "${video_format}" >&2
exit 64
;;
esac
}
resolve_video_size() { resolve_video_size() {
local requested=$1 local requested=$1
if [[ "${requested}" != "auto" ]]; then if [[ "${requested}" != "auto" ]]; then
@ -247,6 +283,8 @@ video_args=(-f video4linux2 -framerate "${video_fps}" -video_size "${resolved_vi
if [[ -n "${video_format}" ]]; then if [[ -n "${video_format}" ]]; then
video_args+=(-input_format "${video_format}") video_args+=(-input_format "${video_format}")
fi fi
gst_source_caps="$(gst_video_source_caps)"
gst_decode_chain="$(gst_video_decode_chain)"
quiesce_for_alsa=0 quiesce_for_alsa=0
case "${remote_audio_quiesce_user_audio}" in case "${remote_audio_quiesce_user_audio}" in
@ -333,11 +371,15 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
gst) gst)
case "${remote_pulse_video_mode}" in case "${remote_pulse_video_mode}" in
copy) copy)
if [[ "${video_format}" != "mjpeg" && "${video_format}" != "MJPG" && -n "${video_format}" ]]; then
printf 'gst copy mode only supports mjpeg input, got %s\n' "${video_format}" >&2
exit 64
fi
timeout --signal=INT "$((capture_seconds + 3))" \ timeout --signal=INT "$((capture_seconds + 3))" \
gst-launch-1.0 -q -e \ gst-launch-1.0 -q -e \
matroskamux name=mux ! filesink location="${remote_capture}" \ matroskamux name=mux ! filesink location="${remote_capture}" \
v4l2src device=/dev/video0 do-timestamp=true ! \ v4l2src device=/dev/video0 do-timestamp=true ! \
image/jpeg,width="${resolved_video_size%x*}",height="${resolved_video_size#*x}",framerate="${video_fps}"/1 ! \ ${gst_source_caps} ! \
queue ! mux. \ queue ! mux. \
pulsesrc device="${pulse_source}" do-timestamp=true ! \ pulsesrc device="${pulse_source}" do-timestamp=true ! \
audio/x-raw,rate=48000,channels=2 ! \ audio/x-raw,rate=48000,channels=2 ! \
@ -348,9 +390,11 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
gst-launch-1.0 -q -e \ gst-launch-1.0 -q -e \
matroskamux name=mux ! filesink location="${remote_capture}" \ matroskamux name=mux ! filesink location="${remote_capture}" \
v4l2src device=/dev/video0 do-timestamp=true ! \ v4l2src device=/dev/video0 do-timestamp=true ! \
image/jpeg,width="${resolved_video_size%x*}",height="${resolved_video_size#*x}",framerate="${video_fps}"/1 ! \ ${gst_source_caps} ! \
jpegdec ! videoconvert ! videorate ! video/x-raw,framerate="${video_fps}"/1 ! \ ${gst_decode_chain} \
jpegenc quality=90 ! image/jpeg,framerate="${video_fps}"/1 ! \ videoconvert ! videorate ! video/x-raw,framerate="${video_fps}"/1 ! \
x264enc tune=zerolatency speed-preset=ultrafast key-int-max=1 bitrate=5000 ! \
h264parse ! \
queue ! mux. \ queue ! mux. \
pulsesrc device="${pulse_source}" do-timestamp=true ! \ pulsesrc device="${pulse_source}" do-timestamp=true ! \
audio/x-raw,rate=48000,channels=2 ! \ audio/x-raw,rate=48000,channels=2 ! \

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.14.18" version = "0.14.19"
edition = "2024" edition = "2024"
autobins = false autobins = false