diff --git a/Cargo.lock b/Cargo.lock index c2c4915..5b56c19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.26.3" +version = "0.26.4" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.26.3" +version = "0.26.4" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.26.3" +version = "0.26.4" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index dedde0b..866a479 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.26.3" +version = "0.26.4" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 73e10b7..92648f3 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.26.3" +version = "0.26.4" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 279d75c..49858fe 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.26.3" +version = "0.26.4" edition = "2024" autobins = false 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 index bca6654..fb88c6d 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_seams.rs @@ -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_COVERAGE_PCT: u32 = 32; 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_COVERAGE_PCT: u32 = 34; @@ -73,6 +74,21 @@ fn partial_seam_run_pct_threshold() -> 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. /// /// Inputs: optional pixel-guard tuning. Output: chroma delta threshold. Why: @@ -122,6 +138,55 @@ fn row_bounds(frame: &SampledFrame) -> std::ops::Range { start..end.min(frame.height) } +fn region_luma_stddev( + frame: &SampledFrame, + row_start: usize, + row_end: usize, + col_start: usize, + col_end: usize, +) -> Option { + 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 { + 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. /// /// Inputs: decoded sampled frame. Output: the strongest seam reason, if any. @@ -136,6 +201,7 @@ pub(super) fn seam_reason(frame: &SampledFrame) -> Option = None; @@ -151,6 +217,9 @@ pub(super) fn seam_reason(frame: &SampledFrame) -> Option Option= partial_luma_threshold as u16 { partial_luma_hits += 1; partial_luma_total += u32::from(luma_delta); + if partial_run == 0 { + partial_run_start = col; + } 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 { partial_run = 0; } @@ -200,6 +276,13 @@ pub(super) fn seam_reason(frame: &SampledFrame) -> Option= partial_coverage_threshold && partial_luma_run_pct >= partial_run_threshold && 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); match best_partial { diff --git a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs index cbdfa79..05a05e8 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs @@ -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] /// Verifies low-detail gray slabs freeze out before UVC handoff. fn pixel_guard_rejects_flat_lower_block_frames() {