fix(lesavka): split MJPEG visual seam guard
This commit is contained in:
parent
64eb042c64
commit
567dd6ef09
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
311
server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs
Normal file
311
server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs
Normal 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)
|
||||
}
|
||||
@ -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(
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user