fix(lesavka): split MJPEG visual seam guard

This commit is contained in:
Brad Stein 2026-05-19 07:05:46 -03:00
parent 64eb042c64
commit 567dd6ef09
5 changed files with 340 additions and 259 deletions

View File

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

View File

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

View File

@ -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<PixelSample>,
pub(super) struct SampledFrame {
pub(super) width: usize,
pub(super) height: usize,
pub(super) pixels: Vec<PixelSample>,
}
/// 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<MjpegVisualArtifactReason>
None
}
fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
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<usize> = 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<MjpegVisualArtifactReason> {
flat_block_reason(frame).or_else(|| seam_reason(frame))
}

View File

@ -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<usize> {
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<MjpegVisualArtifactReason> {
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<usize> = 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)
}

View File

@ -221,6 +221,7 @@ fn checker_grid_rgb_frame(width: usize, height: usize) -> Vec<u8> {
}
#[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(
[