fix: avoid false positive UVC seam freezes

This commit is contained in:
Brad Stein 2026-05-19 14:02:05 -03:00
parent e62023573c
commit b82694125e
6 changed files with 137 additions and 7 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.26.3" version = "0.26.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.26.3" version = "0.26.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.26.3" version = "0.26.4"
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.26.3" version = "0.26.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

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

View File

@ -16,7 +16,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.26.3" version = "0.26.4"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -7,6 +7,7 @@ const DEFAULT_PIXEL_GUARD_SEAM_COVERAGE_PCT: u32 = 42;
const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_DELTA: u32 = 28; 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_COVERAGE_PCT: u32 = 32;
const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT: u32 = 12; const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT: u32 = 12;
const DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_BLOCK_STDDEV: u32 = 14;
const DEFAULT_PIXEL_GUARD_CHROMA_DELTA: u32 = 28; const DEFAULT_PIXEL_GUARD_CHROMA_DELTA: u32 = 28;
const DEFAULT_PIXEL_GUARD_CHROMA_COVERAGE_PCT: u32 = 34; const DEFAULT_PIXEL_GUARD_CHROMA_COVERAGE_PCT: u32 = 34;
@ -73,6 +74,21 @@ fn partial_seam_run_pct_threshold() -> usize {
.clamp(4, 60) as usize .clamp(4, 60) as usize
} }
/// Resolve the partial-seam block-texture threshold.
///
/// Inputs: optional pixel-guard tuning. Output: luma stddev threshold. Why:
/// natural shelf/chair/table edges can look like a partial seam on one row,
/// but damaged codec slabs usually have a low-texture block on one side.
fn partial_seam_block_stddev_threshold() -> f64 {
f64::from(
env_u32(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_BLOCK_STDDEV",
DEFAULT_PIXEL_GUARD_PARTIAL_SEAM_BLOCK_STDDEV,
)
.clamp(2, 60),
)
}
/// Resolve the chroma-seam threshold. /// Resolve the chroma-seam threshold.
/// ///
/// Inputs: optional pixel-guard tuning. Output: chroma delta threshold. Why: /// Inputs: optional pixel-guard tuning. Output: chroma delta threshold. Why:
@ -122,6 +138,55 @@ fn row_bounds(frame: &SampledFrame) -> std::ops::Range<usize> {
start..end.min(frame.height) start..end.min(frame.height)
} }
fn region_luma_stddev(
frame: &SampledFrame,
row_start: usize,
row_end: usize,
col_start: usize,
col_end: usize,
) -> Option<f64> {
if row_start >= row_end || col_start >= col_end || row_end > frame.height || col_end > frame.width
{
return None;
}
let mut sum = 0f64;
let mut sum_sq = 0f64;
let mut count = 0usize;
for row in row_start..row_end {
let start = row.saturating_mul(frame.width);
for col in col_start..col_end {
let value = f64::from(frame.pixels[start + col].luma);
sum += value;
sum_sq += value * value;
count += 1;
}
}
if count == 0 {
return None;
}
let count_f = count as f64;
let mean = sum / count_f;
Some((sum_sq / count_f - mean * mean).max(0.0).sqrt())
}
fn partial_seam_block_stddev(
frame: &SampledFrame,
row: usize,
col_start: usize,
col_end: usize,
) -> Option<f64> {
let window = frame.height.saturating_div(12).max(3);
let above_start = row.saturating_sub(window);
let above = region_luma_stddev(frame, above_start, row, col_start, col_end);
let below_end = row.saturating_add(window).min(frame.height);
let below = region_luma_stddev(frame, row, below_end, col_start, col_end);
match (above, below) {
(Some(above), Some(below)) => Some(above.min(below)),
(Some(value), None) | (None, Some(value)) => Some(value),
(None, None) => None,
}
}
/// Detect visible luma/chroma tile seams in a sampled MJPEG frame. /// Detect visible luma/chroma tile seams in a sampled MJPEG frame.
/// ///
/// Inputs: decoded sampled frame. Output: the strongest seam reason, if any. /// Inputs: decoded sampled frame. Output: the strongest seam reason, if any.
@ -136,6 +201,7 @@ pub(super) fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactRea
let partial_luma_threshold = partial_seam_delta_threshold(); let partial_luma_threshold = partial_seam_delta_threshold();
let partial_coverage_threshold = partial_seam_coverage_pct_threshold(); let partial_coverage_threshold = partial_seam_coverage_pct_threshold();
let partial_run_threshold = partial_seam_run_pct_threshold(); let partial_run_threshold = partial_seam_run_pct_threshold();
let partial_block_stddev_threshold = partial_seam_block_stddev_threshold();
let chroma_threshold = chroma_delta_threshold(); let chroma_threshold = chroma_delta_threshold();
let chroma_coverage_threshold = chroma_coverage_pct_threshold(); let chroma_coverage_threshold = chroma_coverage_pct_threshold();
let mut best_luma: Option<(usize, usize, u16)> = None; let mut best_luma: Option<(usize, usize, u16)> = None;
@ -151,6 +217,9 @@ pub(super) fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactRea
let mut luma_hits = 0usize; let mut luma_hits = 0usize;
let mut partial_luma_hits = 0usize; let mut partial_luma_hits = 0usize;
let mut partial_run = 0usize; let mut partial_run = 0usize;
let mut partial_run_start = 0usize;
let mut longest_partial_run_start = 0usize;
let mut longest_partial_run_end = 0usize;
let mut longest_partial_run = 0usize; let mut longest_partial_run = 0usize;
let mut chroma_hits = 0usize; let mut chroma_hits = 0usize;
let mut luma_total = 0u32; let mut luma_total = 0u32;
@ -170,8 +239,15 @@ pub(super) fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactRea
if luma_delta >= partial_luma_threshold as u16 { if luma_delta >= partial_luma_threshold as u16 {
partial_luma_hits += 1; partial_luma_hits += 1;
partial_luma_total += u32::from(luma_delta); partial_luma_total += u32::from(luma_delta);
if partial_run == 0 {
partial_run_start = col;
}
partial_run += 1; partial_run += 1;
longest_partial_run = longest_partial_run.max(partial_run); if partial_run > longest_partial_run {
longest_partial_run = partial_run;
longest_partial_run_start = partial_run_start;
longest_partial_run_end = col + 1;
}
} else { } else {
partial_run = 0; partial_run = 0;
} }
@ -200,6 +276,13 @@ pub(super) fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactRea
&& partial_luma_coverage >= partial_coverage_threshold && partial_luma_coverage >= partial_coverage_threshold
&& partial_luma_run_pct >= partial_run_threshold && partial_luma_run_pct >= partial_run_threshold
&& partial_luma_avg >= partial_luma_threshold as u16 && partial_luma_avg >= partial_luma_threshold as u16
&& partial_seam_block_stddev(
frame,
row,
longest_partial_run_start,
longest_partial_run_end,
)
.is_some_and(|stddev| stddev <= partial_block_stddev_threshold)
{ {
partial_candidate_rows.push(row); partial_candidate_rows.push(row);
match best_partial { match best_partial {

View File

@ -280,6 +280,53 @@ fn pixel_guard_accepts_regular_synthetic_checker_grids() {
); );
} }
#[test]
/// Verifies textured scene edges are not misclassified as partial tile tears.
fn pixel_guard_accepts_textured_partial_scene_edges() {
temp_env::with_vars(
[
("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")),
(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_DELTA",
Some("28"),
),
(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT",
Some("32"),
),
(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT",
Some("12"),
),
(
"LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_BLOCK_STDDEV",
Some("14"),
),
],
|| {
let width = 320;
let height = 180;
let mut frame = smooth_rgb_frame(width, height);
let start_x = width / 4;
let end_x = start_x + width / 3;
for y in height / 2..height {
for x in start_x..end_x {
let offset = (y * width + x) * 3;
let texture = ((x * 17 + y * 11) % 96) as u8;
let value = 56u8.saturating_add(texture);
frame[offset] = value;
frame[offset + 1] = value.saturating_add((x % 23) as u8);
frame[offset + 2] = value.saturating_sub((y % 19) as u8);
}
}
assert_eq!(
super::mjpeg_visual_guard::sampled_rgb_frame_for_test(width, height, &frame),
None
);
},
);
}
#[test] #[test]
/// Verifies low-detail gray slabs freeze out before UVC handoff. /// Verifies low-detail gray slabs freeze out before UVC handoff.
fn pixel_guard_rejects_flat_lower_block_frames() { fn pixel_guard_rejects_flat_lower_block_frames() {