diff --git a/Cargo.lock b/Cargo.lock index 8bcfd07..637494f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.25.1" +version = "0.25.2" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.25.1" +version = "0.25.2" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.25.1" +version = "0.25.2" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 88cf568..f0b9ba3 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.25.1" +version = "0.25.2" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 0a1afbf..a8fbee7 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.25.1" +version = "0.25.2" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 4d96235..7483eb1 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.25.1" +version = "0.25.2" edition = "2024" autobins = false 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 e8054b2..67fd5c6 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 @@ -339,6 +339,9 @@ fn seam_reason(frame: &SampledFrame) -> Option { 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; @@ -385,6 +388,7 @@ fn seam_reason(frame: &SampledFrame) -> Option { 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 @@ -397,6 +401,7 @@ fn seam_reason(frame: &SampledFrame) -> Option { && 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 @@ -415,6 +420,7 @@ fn seam_reason(frame: &SampledFrame) -> Option { } } 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 @@ -425,6 +431,9 @@ fn seam_reason(frame: &SampledFrame) -> Option { } 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, @@ -432,6 +441,9 @@ fn seam_reason(frame: &SampledFrame) -> Option { }); } 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, @@ -439,6 +451,9 @@ fn seam_reason(frame: &SampledFrame) -> Option { 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, @@ -446,6 +461,52 @@ fn seam_reason(frame: &SampledFrame) -> Option { }) } +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/tests.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs index 561600c..d6312bd 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs @@ -206,6 +206,20 @@ fn smooth_rgb_frame(width: usize, height: usize) -> Vec { pixels } +fn checker_grid_rgb_frame(width: usize, height: usize) -> Vec { + let mut pixels = Vec::with_capacity(width * height * 3); + for y in 0..height { + for x in 0..width { + let block = ((x / 16) + (y / 16)) % 2; + let base = if block == 0 { 42 } else { 214 }; + pixels.push((base + (x % 17)) as u8); + pixels.push((base + (y % 19)) as u8); + pixels.push((base + ((x + y) % 13)) as u8); + } + } + pixels +} + #[test] fn pixel_guard_accepts_smooth_frames() { temp_env::with_vars( @@ -220,6 +234,35 @@ fn pixel_guard_accepts_smooth_frames() { ); } +#[test] +fn pixel_guard_accepts_regular_synthetic_checker_grids() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH", Some("160")), + ( + "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"), + ), + ], + || { + let frame = checker_grid_rgb_frame(320, 180); + assert_eq!( + super::mjpeg_visual_guard::sampled_rgb_frame_for_test(320, 180, &frame), + None + ); + }, + ); +} + #[test] fn pixel_guard_rejects_flat_lower_block_frames() { temp_env::with_vars(