diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 1c5883d..df67d89 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -983,7 +983,7 @@ "server/src/bin/lesavka_uvc/coverage_model.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 288 + "loc": 289 }, "server/src/bin/lesavka_uvc/coverage_startup.rs": { "clippy_warnings": 0, @@ -1308,7 +1308,7 @@ "server/src/video_sinks/hevc_mjpeg_guard.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 383 + "loc": 385 }, "server/src/video_sinks/hevc_mjpeg_guard/mjpeg_frame_inspection.rs": { "clippy_warnings": 0, @@ -1317,13 +1317,18 @@ }, "server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs": { "clippy_warnings": 0, - "doc_debt": 8, - "loc": 475 + "doc_debt": 7, + "loc": 293 + }, + "server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs": { + "clippy_warnings": 0, + "doc_debt": 0, + "loc": 311 }, "server/src/video_sinks/hevc_mjpeg_guard/tests.rs": { "clippy_warnings": 0, - "doc_debt": 11, - "loc": 422 + "doc_debt": 7, + "loc": 471 }, "server/src/video_sinks/mjpeg_spool.rs": { "clippy_warnings": 0, diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index 2b65f06..e012611 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -2,6 +2,8 @@ use crate::video_support::env_u32; #[path = "hevc_mjpeg_guard/mjpeg_frame_inspection.rs"] mod mjpeg_frame_inspection; +#[path = "hevc_mjpeg_guard/mjpeg_visual_seams.rs"] +mod mjpeg_visual_seams; #[path = "hevc_mjpeg_guard/mjpeg_visual_guard.rs"] mod mjpeg_visual_guard; diff --git a/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs index 67fd5c6..231780f 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs @@ -4,16 +4,11 @@ use jpeg_decoder::{Decoder as JpegDecoder, PixelFormat}; use crate::video_support::env_u32; +use super::mjpeg_visual_seams::seam_reason; + const DEFAULT_PIXEL_GUARD_SAMPLE_WIDTH: u32 = 160; const DEFAULT_PIXEL_GUARD_FLAT_STDDEV: u32 = 6; const DEFAULT_PIXEL_GUARD_FLAT_RUN_PCT: u32 = 16; -const DEFAULT_PIXEL_GUARD_SEAM_DELTA: u32 = 34; -const DEFAULT_PIXEL_GUARD_SEAM_COVERAGE_PCT: u32 = 42; -const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_DELTA: u32 = 28; -const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT: u32 = 32; -const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT: u32 = 12; -const DEFAULT_PIXEL_GUARD_CHROMA_DELTA: u32 = 28; -const DEFAULT_PIXEL_GUARD_CHROMA_COVERAGE_PCT: u32 = 34; const DEFAULT_PIXEL_GUARD_MIN_PIXELS: u32 = 160 * 90; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -42,17 +37,17 @@ pub(in crate::video_sinks) enum MjpegVisualArtifactReason { } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct PixelSample { - luma: i16, - cb: i16, - cr: i16, +pub(super) struct PixelSample { + pub(super) luma: i16, + pub(super) cb: i16, + pub(super) cr: i16, } #[derive(Debug)] -struct SampledFrame { - width: usize, - height: usize, - pixels: Vec, +pub(super) struct SampledFrame { + pub(super) width: usize, + pub(super) height: usize, + pub(super) pixels: Vec, } /// Decide whether decoded-pixel artifact filtering is active. @@ -98,62 +93,6 @@ fn flat_run_pct_threshold() -> usize { .clamp(5, 60) as usize } -fn seam_delta_threshold() -> i16 { - env_u32( - "LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA", - DEFAULT_PIXEL_GUARD_SEAM_DELTA, - ) - .clamp(8, 96) as i16 -} - -fn seam_coverage_pct_threshold() -> usize { - env_u32( - "LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT", - DEFAULT_PIXEL_GUARD_SEAM_COVERAGE_PCT, - ) - .clamp(10, 90) as usize -} - -fn partial_seam_delta_threshold() -> i16 { - env_u32( - "LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_DELTA", - DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_DELTA, - ) - .clamp(8, 96) as i16 -} - -fn partial_seam_coverage_pct_threshold() -> usize { - env_u32( - "LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT", - DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT, - ) - .clamp(10, 90) as usize -} - -fn partial_seam_run_pct_threshold() -> usize { - env_u32( - "LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT", - DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT, - ) - .clamp(4, 60) as usize -} - -fn chroma_delta_threshold() -> i16 { - env_u32( - "LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_DELTA", - DEFAULT_PIXEL_GUARD_CHROMA_DELTA, - ) - .clamp(8, 96) as i16 -} - -fn chroma_coverage_pct_threshold() -> usize { - env_u32( - "LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_COVERAGE_PCT", - DEFAULT_PIXEL_GUARD_CHROMA_COVERAGE_PCT, - ) - .clamp(10, 90) as usize -} - fn min_pixels_for_guard() -> usize { env_u32( "LESAVKA_UVC_MJPEG_PIXEL_GUARD_MIN_PIXELS", @@ -325,188 +264,6 @@ fn flat_block_reason(frame: &SampledFrame) -> Option None } -fn seam_reason(frame: &SampledFrame) -> Option { - if frame.height < 4 || frame.width < 8 { - return None; - } - let luma_threshold = seam_delta_threshold(); - let luma_coverage_threshold = seam_coverage_pct_threshold(); - let partial_luma_threshold = partial_seam_delta_threshold(); - let partial_coverage_threshold = partial_seam_coverage_pct_threshold(); - let partial_run_threshold = partial_seam_run_pct_threshold(); - let chroma_threshold = chroma_delta_threshold(); - let chroma_coverage_threshold = chroma_coverage_pct_threshold(); - let mut best_luma: Option<(usize, usize, u16)> = None; - let mut best_partial: Option<(usize, usize, usize, u16)> = None; - let mut best_chroma: Option<(usize, usize, u16)> = None; - let mut luma_candidate_rows = Vec::new(); - let mut partial_candidate_rows = Vec::new(); - let mut chroma_candidate_rows = Vec::new(); - - for row in row_bounds(frame).skip(1) { - let prev_start = (row - 1) * frame.width; - let row_start = row * frame.width; - let mut luma_hits = 0usize; - let mut partial_luma_hits = 0usize; - let mut partial_run = 0usize; - let mut longest_partial_run = 0usize; - let mut chroma_hits = 0usize; - let mut luma_total = 0u32; - let mut partial_luma_total = 0u32; - let mut chroma_total = 0u32; - for col in 0..frame.width { - let before = frame.pixels[prev_start + col]; - let after = frame.pixels[row_start + col]; - let luma_delta = (after.luma - before.luma).unsigned_abs(); - let chroma_delta = ((after.cb - before.cb).unsigned_abs() - + (after.cr - before.cr).unsigned_abs()) - / 2; - luma_total += u32::from(luma_delta); - chroma_total += u32::from(chroma_delta); - if luma_delta >= luma_threshold as u16 { - luma_hits += 1; - } - if luma_delta >= partial_luma_threshold as u16 { - partial_luma_hits += 1; - partial_luma_total += u32::from(luma_delta); - partial_run += 1; - longest_partial_run = longest_partial_run.max(partial_run); - } else { - partial_run = 0; - } - if chroma_delta >= chroma_threshold as u16 { - chroma_hits += 1; - } - } - let luma_coverage = luma_hits.saturating_mul(100) / frame.width; - let partial_luma_coverage = partial_luma_hits.saturating_mul(100) / frame.width; - let partial_luma_run_pct = longest_partial_run.saturating_mul(100) / frame.width; - let chroma_coverage = chroma_hits.saturating_mul(100) / frame.width; - let luma_avg = (luma_total / frame.width.max(1) as u32) as u16; - let partial_luma_avg = - (partial_luma_total / partial_luma_hits.max(1) as u32) as u16; - let chroma_avg = (chroma_total / frame.width.max(1) as u32) as u16; - - if luma_coverage >= luma_coverage_threshold && luma_avg >= luma_threshold as u16 { - luma_candidate_rows.push(row); - match best_luma { - Some((_, best_coverage, best_avg)) - if best_coverage > luma_coverage - || (best_coverage == luma_coverage && best_avg >= luma_avg) => {} - _ => best_luma = Some((row, luma_coverage, luma_avg)), - } - } - if row >= frame.height / 3 - && partial_luma_coverage >= partial_coverage_threshold - && partial_luma_run_pct >= partial_run_threshold - && partial_luma_avg >= partial_luma_threshold as u16 - { - partial_candidate_rows.push(row); - match best_partial { - Some((_, best_coverage, best_run, best_avg)) - if best_coverage > partial_luma_coverage - || (best_coverage == partial_luma_coverage && best_run > partial_luma_run_pct) - || (best_coverage == partial_luma_coverage - && best_run == partial_luma_run_pct - && best_avg >= partial_luma_avg) => {} - _ => { - best_partial = Some(( - row, - partial_luma_coverage, - partial_luma_run_pct, - partial_luma_avg, - )); - } - } - } - if chroma_coverage >= chroma_coverage_threshold && chroma_avg >= chroma_threshold as u16 { - chroma_candidate_rows.push(row); - match best_chroma { - Some((_, best_coverage, best_avg)) - if best_coverage > chroma_coverage - || (best_coverage == chroma_coverage && best_avg >= chroma_avg) => {} - _ => best_chroma = Some((row, chroma_coverage, chroma_avg)), - } - } - } - - if let Some((row, coverage, delta)) = best_chroma { - if regular_texture_seam_rows(frame, &chroma_candidate_rows) { - return None; - } - return Some(MjpegVisualArtifactReason::ChromaTileSeam { - row_pct: pct(row, frame.height), - coverage_pct: coverage.min(100) as u8, - delta, - }); - } - if let Some((row, coverage, run_pct, delta)) = best_partial { - if regular_texture_seam_rows(frame, &partial_candidate_rows) { - return None; - } - return Some(MjpegVisualArtifactReason::PartialTileSeam { - row_pct: pct(row, frame.height), - coverage_pct: coverage.min(100) as u8, - run_pct: run_pct.min(100) as u8, - delta, - }); - } - if regular_texture_seam_rows(frame, &luma_candidate_rows) { - return None; - } - best_luma.map(|(row, coverage, delta)| MjpegVisualArtifactReason::HorizontalTileSeam { - row_pct: pct(row, frame.height), - coverage_pct: coverage.min(100) as u8, - delta, - }) -} - -fn regular_texture_seam_rows(frame: &SampledFrame, rows: &[usize]) -> bool { - // Synthetic calibration grids create many evenly spaced hard edges; unlike a - // tear, they are regular across the guard window rather than isolated. - if rows.len() < 6 { - return false; - } - let mut unique_rows = rows.to_vec(); - unique_rows.sort_unstable(); - unique_rows.dedup(); - if unique_rows.len() < 6 { - return false; - } - - let bounds = row_bounds(frame); - let row_span = unique_rows - .last() - .copied() - .unwrap_or(0) - .saturating_sub(unique_rows[0]); - let guard_span = bounds.end.saturating_sub(bounds.start).max(1); - if row_span.saturating_mul(100) < guard_span.saturating_mul(30) { - return false; - } - - let mut gaps: Vec = unique_rows - .windows(2) - .map(|pair| pair[1].saturating_sub(pair[0])) - .filter(|gap| *gap > 0) - .collect(); - if gaps.len() < 5 { - return false; - } - gaps.sort_unstable(); - let median_gap = gaps[gaps.len() / 2]; - if median_gap == 0 || median_gap > frame.height.saturating_div(8).max(2) { - return false; - } - - let tolerance = median_gap.saturating_div(3).max(1); - let consistent_gaps = gaps - .iter() - .filter(|gap| gap.abs_diff(median_gap) <= tolerance) - .count(); - consistent_gaps.saturating_mul(100) >= gaps.len().saturating_mul(70) -} - fn inspect_sampled_frame_for_artifacts(frame: &SampledFrame) -> Option { flat_block_reason(frame).or_else(|| seam_reason(frame)) } diff --git a/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs new file mode 100644 index 0000000..bca6654 --- /dev/null +++ b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs @@ -0,0 +1,311 @@ +use crate::video_support::env_u32; + +use super::mjpeg_visual_guard::{MjpegVisualArtifactReason, SampledFrame}; + +const DEFAULT_PIXEL_GUARD_SEAM_DELTA: u32 = 34; +const DEFAULT_PIXEL_GUARD_SEAM_COVERAGE_PCT: u32 = 42; +const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_DELTA: u32 = 28; +const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT: u32 = 32; +const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT: u32 = 12; +const DEFAULT_PIXEL_GUARD_CHROMA_DELTA: u32 = 28; +const DEFAULT_PIXEL_GUARD_CHROMA_COVERAGE_PCT: u32 = 34; + +/// Resolve the full-width luma seam threshold. +/// +/// Inputs: optional pixel-guard tuning. Output: luma delta threshold. Why: +/// hard horizontal jumps are useful evidence only when tuned above normal +/// camera texture, otherwise synthetic grids look like damage. +fn seam_delta_threshold() -> i16 { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA", + DEFAULT_PIXEL_GUARD_SEAM_DELTA, + ) + .clamp(8, 96) as i16 +} + +/// Resolve the required full-width seam coverage. +/// +/// Inputs: optional pixel-guard tuning. Output: percent of sampled row. Why: +/// isolated noisy pixels should not freeze the frame, but a broad tear should. +fn seam_coverage_pct_threshold() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT", + DEFAULT_PIXEL_GUARD_SEAM_COVERAGE_PCT, + ) + .clamp(10, 90) as usize +} + +/// Resolve the partial-seam luma threshold. +/// +/// Inputs: optional pixel-guard tuning. Output: luma delta threshold. Why: +/// partial-width tears are subtler than full seams, so they get separate +/// thresholds instead of weakening the full-width detector. +fn partial_seam_delta_threshold() -> i16 { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_DELTA", + DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_DELTA, + ) + .clamp(8, 96) as i16 +} + +/// Resolve the required partial-seam row coverage. +/// +/// Inputs: optional pixel-guard tuning. Output: percent of sampled row. Why: +/// the detector should catch a torn block without treating small edge detail +/// as conference-breaking corruption. +fn partial_seam_coverage_pct_threshold() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT", + DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT, + ) + .clamp(10, 90) as usize +} + +/// Resolve the contiguous partial-seam run threshold. +/// +/// Inputs: optional pixel-guard tuning. Output: percent of sampled row. Why: +/// requiring a contiguous run separates block tears from scattered texture. +fn partial_seam_run_pct_threshold() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT", + DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT, + ) + .clamp(4, 60) as usize +} + +/// Resolve the chroma-seam threshold. +/// +/// Inputs: optional pixel-guard tuning. Output: chroma delta threshold. Why: +/// some damaged MJPEG frames keep plausible luma while their color planes jump +/// across a tile boundary. +fn chroma_delta_threshold() -> i16 { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_DELTA", + DEFAULT_PIXEL_GUARD_CHROMA_DELTA, + ) + .clamp(8, 96) as i16 +} + +/// Resolve the required chroma-seam row coverage. +/// +/// Inputs: optional pixel-guard tuning. Output: percent of sampled row. Why: +/// broad chroma shifts are visible corruption; narrow color edges are normal +/// scene content. +fn chroma_coverage_pct_threshold() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_COVERAGE_PCT", + DEFAULT_PIXEL_GUARD_CHROMA_COVERAGE_PCT, + ) + .clamp(10, 90) as usize +} + +/// Convert a row/column count into a rounded percentage. +/// +/// Inputs: numerator and denominator counts. Output: 0..=100 percent. Why: +/// artifact reasons are telemetry payloads, so they should be stable and easy +/// to read rather than raw sampled-row counts. +fn pct(numerator: usize, denominator: usize) -> u8 { + if denominator == 0 { + return 0; + } + ((numerator.saturating_mul(100) + denominator / 2) / denominator).min(100) as u8 +} + +/// Return the sampled rows considered by seam detection. +/// +/// Inputs: sampled frame. Output: row range. Why: the top setup rows and very +/// bottom edge are noisy on several cameras, so the detector focuses on the +/// stable body of the image. +fn row_bounds(frame: &SampledFrame) -> std::ops::Range { + let start = frame.height / 6; + let end = (frame.height.saturating_mul(19) / 20).max(start + 1); + start..end.min(frame.height) +} + +/// Detect visible luma/chroma tile seams in a sampled MJPEG frame. +/// +/// Inputs: decoded sampled frame. Output: the strongest seam reason, if any. +/// Why: the UVC path can receive syntactically valid JPEGs that are visibly +/// torn, and the safer user experience is to repeat the last good frame. +pub(super) fn seam_reason(frame: &SampledFrame) -> Option { + if frame.height < 4 || frame.width < 8 { + return None; + } + let luma_threshold = seam_delta_threshold(); + let luma_coverage_threshold = seam_coverage_pct_threshold(); + let partial_luma_threshold = partial_seam_delta_threshold(); + let partial_coverage_threshold = partial_seam_coverage_pct_threshold(); + let partial_run_threshold = partial_seam_run_pct_threshold(); + let chroma_threshold = chroma_delta_threshold(); + let chroma_coverage_threshold = chroma_coverage_pct_threshold(); + let mut best_luma: Option<(usize, usize, u16)> = None; + let mut best_partial: Option<(usize, usize, usize, u16)> = None; + let mut best_chroma: Option<(usize, usize, u16)> = None; + let mut luma_candidate_rows = Vec::new(); + let mut partial_candidate_rows = Vec::new(); + let mut chroma_candidate_rows = Vec::new(); + + for row in row_bounds(frame).skip(1) { + let prev_start = (row - 1) * frame.width; + let row_start = row * frame.width; + let mut luma_hits = 0usize; + let mut partial_luma_hits = 0usize; + let mut partial_run = 0usize; + let mut longest_partial_run = 0usize; + let mut chroma_hits = 0usize; + let mut luma_total = 0u32; + let mut partial_luma_total = 0u32; + let mut chroma_total = 0u32; + for col in 0..frame.width { + let before = frame.pixels[prev_start + col]; + let after = frame.pixels[row_start + col]; + let luma_delta = (after.luma - before.luma).unsigned_abs(); + let chroma_delta = + ((after.cb - before.cb).unsigned_abs() + (after.cr - before.cr).unsigned_abs()) / 2; + luma_total += u32::from(luma_delta); + chroma_total += u32::from(chroma_delta); + if luma_delta >= luma_threshold as u16 { + luma_hits += 1; + } + if luma_delta >= partial_luma_threshold as u16 { + partial_luma_hits += 1; + partial_luma_total += u32::from(luma_delta); + partial_run += 1; + longest_partial_run = longest_partial_run.max(partial_run); + } else { + partial_run = 0; + } + if chroma_delta >= chroma_threshold as u16 { + chroma_hits += 1; + } + } + let luma_coverage = luma_hits.saturating_mul(100) / frame.width; + let partial_luma_coverage = partial_luma_hits.saturating_mul(100) / frame.width; + let partial_luma_run_pct = longest_partial_run.saturating_mul(100) / frame.width; + let chroma_coverage = chroma_hits.saturating_mul(100) / frame.width; + let luma_avg = (luma_total / frame.width.max(1) as u32) as u16; + let partial_luma_avg = (partial_luma_total / partial_luma_hits.max(1) as u32) as u16; + let chroma_avg = (chroma_total / frame.width.max(1) as u32) as u16; + + if luma_coverage >= luma_coverage_threshold && luma_avg >= luma_threshold as u16 { + luma_candidate_rows.push(row); + match best_luma { + Some((_, best_coverage, best_avg)) + if best_coverage > luma_coverage + || (best_coverage == luma_coverage && best_avg >= luma_avg) => {} + _ => best_luma = Some((row, luma_coverage, luma_avg)), + } + } + if row >= frame.height / 3 + && partial_luma_coverage >= partial_coverage_threshold + && partial_luma_run_pct >= partial_run_threshold + && partial_luma_avg >= partial_luma_threshold as u16 + { + partial_candidate_rows.push(row); + match best_partial { + Some((_, best_coverage, best_run, best_avg)) + if best_coverage > partial_luma_coverage + || (best_coverage == partial_luma_coverage && best_run > partial_luma_run_pct) + || (best_coverage == partial_luma_coverage + && best_run == partial_luma_run_pct + && best_avg >= partial_luma_avg) => {} + _ => { + best_partial = Some(( + row, + partial_luma_coverage, + partial_luma_run_pct, + partial_luma_avg, + )); + } + } + } + if chroma_coverage >= chroma_coverage_threshold && chroma_avg >= chroma_threshold as u16 { + chroma_candidate_rows.push(row); + match best_chroma { + Some((_, best_coverage, best_avg)) + if best_coverage > chroma_coverage + || (best_coverage == chroma_coverage && best_avg >= chroma_avg) => {} + _ => best_chroma = Some((row, chroma_coverage, chroma_avg)), + } + } + } + + if let Some((row, coverage, delta)) = best_chroma { + if regular_texture_seam_rows(frame, &chroma_candidate_rows) { + return None; + } + return Some(MjpegVisualArtifactReason::ChromaTileSeam { + row_pct: pct(row, frame.height), + coverage_pct: coverage.min(100) as u8, + delta, + }); + } + if let Some((row, coverage, run_pct, delta)) = best_partial { + if regular_texture_seam_rows(frame, &partial_candidate_rows) { + return None; + } + return Some(MjpegVisualArtifactReason::PartialTileSeam { + row_pct: pct(row, frame.height), + coverage_pct: coverage.min(100) as u8, + run_pct: run_pct.min(100) as u8, + delta, + }); + } + if regular_texture_seam_rows(frame, &luma_candidate_rows) { + return None; + } + best_luma.map(|(row, coverage, delta)| MjpegVisualArtifactReason::HorizontalTileSeam { + row_pct: pct(row, frame.height), + coverage_pct: coverage.min(100) as u8, + delta, + }) +} + +/// Decide whether seam rows are regular scene texture rather than corruption. +/// +/// Inputs: sampled frame and candidate seam rows. Output: true for regular +/// repeated grid/texture edges. Why: calibration grids create many evenly +/// spaced hard edges; a damaged frame usually has isolated tear lines. +fn regular_texture_seam_rows(frame: &SampledFrame, rows: &[usize]) -> bool { + if rows.len() < 6 { + return false; + } + let mut unique_rows = rows.to_vec(); + unique_rows.sort_unstable(); + unique_rows.dedup(); + if unique_rows.len() < 6 { + return false; + } + + let bounds = row_bounds(frame); + let row_span = unique_rows + .last() + .copied() + .unwrap_or(0) + .saturating_sub(unique_rows[0]); + let guard_span = bounds.end.saturating_sub(bounds.start).max(1); + if row_span.saturating_mul(100) < guard_span.saturating_mul(30) { + return false; + } + + let mut gaps: Vec = unique_rows + .windows(2) + .map(|pair| pair[1].saturating_sub(pair[0])) + .filter(|gap| *gap > 0) + .collect(); + if gaps.len() < 5 { + return false; + } + gaps.sort_unstable(); + let median_gap = gaps[gaps.len() / 2]; + if median_gap == 0 || median_gap > frame.height.saturating_div(8).max(2) { + return false; + } + + let tolerance = median_gap.saturating_div(3).max(1); + let consistent_gaps = gaps + .iter() + .filter(|gap| gap.abs_diff(median_gap) <= tolerance) + .count(); + consistent_gaps.saturating_mul(100) >= gaps.len().saturating_mul(70) +} diff --git a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs index d6312bd..ad8002d 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs @@ -221,6 +221,7 @@ fn checker_grid_rgb_frame(width: usize, height: usize) -> Vec { } #[test] +/// Verifies smooth camera-like frames are not treated as visual corruption. fn pixel_guard_accepts_smooth_frames() { temp_env::with_vars( [ @@ -235,6 +236,7 @@ fn pixel_guard_accepts_smooth_frames() { } #[test] +/// Verifies calibration grids do not look like damaged MJPEG seams. fn pixel_guard_accepts_regular_synthetic_checker_grids() { temp_env::with_vars( [ @@ -264,6 +266,7 @@ fn pixel_guard_accepts_regular_synthetic_checker_grids() { } #[test] +/// Verifies low-detail gray slabs freeze out before UVC handoff. fn pixel_guard_rejects_flat_lower_block_frames() { temp_env::with_vars( [ @@ -292,6 +295,7 @@ fn pixel_guard_rejects_flat_lower_block_frames() { } #[test] +/// Verifies broad horizontal tile seams are detected. fn pixel_guard_rejects_rectangular_tile_seams() { temp_env::with_vars( [ @@ -333,6 +337,7 @@ fn pixel_guard_rejects_rectangular_tile_seams() { } #[test] +/// Verifies partial-width tile tears are detected. fn pixel_guard_rejects_partial_width_tile_seams() { temp_env::with_vars( [ @@ -371,6 +376,7 @@ fn pixel_guard_rejects_partial_width_tile_seams() { } #[test] +/// Verifies chroma-only tile shifts are detected. fn pixel_guard_rejects_chroma_shifted_tile_seams() { temp_env::with_vars( [