From 2df5eb872b0f7e7a6ae8d09230ef295ddc333172 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 18 May 2026 21:05:11 -0300 Subject: [PATCH] fix(server): freeze tiled UVC-bound MJPEG artifacts --- Cargo.lock | 13 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- docs/operational-env.md | 12 + server/Cargo.toml | 3 +- server/src/video_sinks/hevc_mjpeg_guard.rs | 10 +- .../hevc_mjpeg_guard/mjpeg_visual_guard.rs | 475 ++++++++++++++++++ .../src/video_sinks/hevc_mjpeg_guard/tests.rs | 164 ++++++ 8 files changed, 674 insertions(+), 7 deletions(-) create mode 100644 server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs diff --git a/Cargo.lock b/Cargo.lock index 3e710e9..6425cb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1622,6 +1622,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.91" @@ -1652,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.23.1" +version = "0.23.2" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.23.1" +version = "0.23.2" dependencies = [ "anyhow", "base64", @@ -1698,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.23.1" +version = "0.23.2" dependencies = [ "anyhow", "base64", @@ -1708,6 +1714,7 @@ dependencies = [ "gstreamer", "gstreamer-app", "gstreamer-video", + "jpeg-decoder", "lesavka_common", "libc", "prost-build", diff --git a/client/Cargo.toml b/client/Cargo.toml index b8da5ab..b7c7de5 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.23.1" +version = "0.23.2" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index e4d542f..b207aa8 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.23.1" +version = "0.23.2" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index b7bf0c6..aefc0d6 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -355,6 +355,18 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_MAXPAYLOAD_LIMIT` | server hardware/device override | | `LESAVKA_UVC_MJPEG` | server hardware/device override | | `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC` | UVC helper MJPEG budget guard; derives a per-frame byte cap from target FPS when `LESAVKA_UVC_FRAME_MAX_BYTES` is unset; non-bulk UVC is additionally capped by `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD` | server MJPEG pixel-artifact guard toggle; defaults on and decodes UVC-bound MJPEG frames before spool so grey slabs, hard tile seams, and chroma-tinted block tears freeze the last good frame instead of reaching the RCT | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_COVERAGE_PCT` | server MJPEG pixel-artifact guard threshold; minimum sampled-row coverage for chroma tile seams, default `34` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_DELTA` | server MJPEG pixel-artifact guard threshold; sampled chroma jump that marks a row as suspicious, default `28` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_RUN_PCT` | server MJPEG pixel-artifact guard threshold; minimum frame-height percentage for a flat grey/cyan slab, default `16` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_STDDEV` | server MJPEG pixel-artifact guard threshold; maximum sampled-row luma standard deviation for flat slab detection, default `6` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_MIN_PIXELS` | server MJPEG pixel-artifact guard floor; images smaller than this skip pixel decoding, default `14400` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_COVERAGE_PCT` | server MJPEG pixel-artifact guard threshold; minimum sampled-row coverage for partial-width mosaic/tile seams, default `32` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_DELTA` | server MJPEG pixel-artifact guard threshold; sampled luma jump for partial-width mosaic/tile seams, default `28` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_PARTIAL_SEAM_RUN_PCT` | server MJPEG pixel-artifact guard threshold; minimum contiguous sampled-row run for partial-width mosaic/tile seams, default `12` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH` | server MJPEG pixel-artifact guard sampling width; defaults to `160` pixels and is clamped to `64..320` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT` | server MJPEG pixel-artifact guard threshold; minimum sampled-row coverage for hard horizontal tile seams, default `42` | +| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA` | server MJPEG pixel-artifact guard threshold; sampled luma jump that marks a row as suspicious, default `34` | | `LESAVKA_UVC_QUEUE_PACING` | UVC helper queue pacing override; defaults to `0` because the RCT host already paces UVC consumption, and delaying returned buffer requeueing can starve isochronous gadget transfers | | `LESAVKA_UVC_RESTART_DELAY_MS` | UVC control helper supervisor restart delay after helper exit or failure; defaults to `1000` | | `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override | diff --git a/server/Cargo.toml b/server/Cargo.toml index 19e708d..2261a2e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.23.1" +version = "0.23.2" edition = "2024" autobins = false @@ -35,6 +35,7 @@ futures-util = "0.3" gstreamer = { version = "0.23", features = ["v1_22"] } gstreamer-app = { version = "0.23", features = ["v1_22"] } gstreamer-video = "0.23" +jpeg-decoder = { version = "0.3", default-features = false } udev = "0.8" prost-types = "0.13" chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index 97ee7f0..2b65f06 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -2,9 +2,10 @@ use crate::video_support::env_u32; #[path = "hevc_mjpeg_guard/mjpeg_frame_inspection.rs"] mod mjpeg_frame_inspection; +#[path = "hevc_mjpeg_guard/mjpeg_visual_guard.rs"] +mod mjpeg_visual_guard; pub(super) use mjpeg_frame_inspection::{inspect_mjpeg_frame, looks_like_complete_jpeg}; - const DEFAULT_HEVC_JPEG_QUALITY: u32 = 72; const DEFAULT_HEVC_SIZE_DROP_PCT: u32 = 45; const DEFAULT_HEVC_MIN_REFERENCE_BYTES: u32 = 64 * 1024; @@ -33,6 +34,9 @@ pub(super) enum DirectMjpegRejectReason { actual_height: u16, }, FlatPayload, + VisualArtifact { + reason: mjpeg_visual_guard::MjpegVisualArtifactReason, + }, SizeCollapse { threshold_bytes: u64, }, @@ -305,6 +309,7 @@ pub(super) fn should_freeze_decoded_mjpeg_frame(previous_bytes: u64, decoded_mjp } !looks_like_complete_jpeg(decoded_mjpeg) || suspiciously_flat_payload(decoded_mjpeg) + || mjpeg_visual_guard::mjpeg_visual_artifact_reason(decoded_mjpeg).is_some() || should_freeze_decoded_mjpeg(previous_bytes, decoded_mjpeg.len()) } @@ -364,6 +369,9 @@ pub(super) fn direct_mjpeg_reject_reason( if suspiciously_flat_payload(mjpeg) { return Some(DirectMjpegRejectReason::FlatPayload); } + if let Some(reason) = mjpeg_visual_guard::mjpeg_visual_artifact_reason(mjpeg) { + return Some(DirectMjpegRejectReason::VisualArtifact { reason }); + } if (mjpeg.len() as u64) < threshold_bytes { return Some(DirectMjpegRejectReason::SizeCollapse { threshold_bytes }); } 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 new file mode 100644 index 0000000..e8054b2 --- /dev/null +++ b/server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs @@ -0,0 +1,475 @@ +use std::io::Cursor; + +use jpeg_decoder::{Decoder as JpegDecoder, PixelFormat}; + +use crate::video_support::env_u32; + +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)] +pub(in crate::video_sinks) enum MjpegVisualArtifactReason { + FlatBlock { + start_row_pct: u8, + end_row_pct: u8, + run_pct: u8, + }, + HorizontalTileSeam { + row_pct: u8, + coverage_pct: u8, + delta: u16, + }, + PartialTileSeam { + row_pct: u8, + coverage_pct: u8, + run_pct: u8, + delta: u16, + }, + ChromaTileSeam { + row_pct: u8, + coverage_pct: u8, + delta: u16, + }, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct PixelSample { + luma: i16, + cb: i16, + cr: i16, +} + +#[derive(Debug)] +struct SampledFrame { + width: usize, + height: usize, + pixels: Vec, +} + +/// Decide whether decoded-pixel artifact filtering is active. +/// +/// Inputs: optional `LESAVKA_UVC_MJPEG_PIXEL_GUARD`. Output: true unless the +/// operator explicitly disables it. Why: compressed JPEG entropy catches blank +/// slabs, but the field failures also include tiled/chroma-corrupt images that +/// only become visible after decoding the JPEG pixels. +pub(super) fn mjpeg_pixel_guard_enabled() -> bool { + std::env::var("LESAVKA_UVC_MJPEG_PIXEL_GUARD") + .ok() + .map(|value| { + let trimmed = value.trim(); + !(trimmed.eq_ignore_ascii_case("0") + || trimmed.eq_ignore_ascii_case("false") + || trimmed.eq_ignore_ascii_case("no") + || trimmed.eq_ignore_ascii_case("off")) + }) + .unwrap_or(true) +} + +fn sample_width() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH", + DEFAULT_PIXEL_GUARD_SAMPLE_WIDTH, + ) + .clamp(64, 320) as usize +} + +fn flat_stddev_threshold() -> f64 { + f64::from(env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_STDDEV", + DEFAULT_PIXEL_GUARD_FLAT_STDDEV, + ) + .clamp(1, 30)) +} + +fn flat_run_pct_threshold() -> usize { + env_u32( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_RUN_PCT", + DEFAULT_PIXEL_GUARD_FLAT_RUN_PCT, + ) + .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", + DEFAULT_PIXEL_GUARD_MIN_PIXELS, + ) + .clamp(4096, 3840 * 2160) as usize +} + +fn pct(numerator: usize, denominator: usize) -> u8 { + if denominator == 0 { + return 0; + } + ((numerator.saturating_mul(100) + denominator / 2) / denominator).min(100) as u8 +} + +fn rgb_to_sample(red: u8, green: u8, blue: u8) -> PixelSample { + let r = i32::from(red); + let g = i32::from(green); + let b = i32::from(blue); + PixelSample { + luma: ((77 * r + 150 * g + 29 * b) >> 8) as i16, + cb: (((-43 * r - 85 * g + 128 * b) >> 8) + 128) as i16, + cr: (((128 * r - 107 * g - 21 * b) >> 8) + 128) as i16, + } +} + +fn sampled_dimensions(width: usize, height: usize) -> (usize, usize) { + let sampled_width = sample_width().min(width).max(1); + let sampled_height = ((height.saturating_mul(sampled_width) + width / 2) / width.max(1)) + .clamp(1, height.max(1)); + (sampled_width, sampled_height) +} + +fn sample_rgb_frame(width: usize, height: usize, pixels: &[u8]) -> Option { + if width == 0 || height == 0 || pixels.len() < width.saturating_mul(height).saturating_mul(3) { + return None; + } + let (sampled_width, sampled_height) = sampled_dimensions(width, height); + let mut sampled = Vec::with_capacity(sampled_width.saturating_mul(sampled_height)); + for sy in 0..sampled_height { + let src_y = sy.saturating_mul(height) / sampled_height; + for sx in 0..sampled_width { + let src_x = sx.saturating_mul(width) / sampled_width; + let offset = (src_y.saturating_mul(width).saturating_add(src_x)).saturating_mul(3); + let red = *pixels.get(offset)?; + let green = *pixels.get(offset + 1)?; + let blue = *pixels.get(offset + 2)?; + sampled.push(rgb_to_sample(red, green, blue)); + } + } + Some(SampledFrame { + width: sampled_width, + height: sampled_height, + pixels: sampled, + }) +} + +fn sample_luma_frame(width: usize, height: usize, pixels: &[u8]) -> Option { + if width == 0 || height == 0 || pixels.len() < width.saturating_mul(height) { + return None; + } + let (sampled_width, sampled_height) = sampled_dimensions(width, height); + let mut sampled = Vec::with_capacity(sampled_width.saturating_mul(sampled_height)); + for sy in 0..sampled_height { + let src_y = sy.saturating_mul(height) / sampled_height; + for sx in 0..sampled_width { + let src_x = sx.saturating_mul(width) / sampled_width; + let luma = i16::from(*pixels.get(src_y.saturating_mul(width).saturating_add(src_x))?); + sampled.push(PixelSample { + luma, + cb: 128, + cr: 128, + }); + } + } + Some(SampledFrame { + width: sampled_width, + height: sampled_height, + pixels: sampled, + }) +} + +fn sample_cmyk_frame(width: usize, height: usize, pixels: &[u8]) -> Option { + if width == 0 || height == 0 || pixels.len() < width.saturating_mul(height).saturating_mul(4) { + return None; + } + let mut rgb = Vec::with_capacity(width.saturating_mul(height).saturating_mul(3)); + for chunk in pixels.chunks_exact(4) { + let c = u16::from(chunk[0]); + let m = u16::from(chunk[1]); + let y = u16::from(chunk[2]); + let k = u16::from(chunk[3]); + rgb.push(255u16.saturating_sub((c + k).min(255)) as u8); + rgb.push(255u16.saturating_sub((m + k).min(255)) as u8); + rgb.push(255u16.saturating_sub((y + k).min(255)) as u8); + } + sample_rgb_frame(width, height, &rgb) +} + +fn decode_jpeg_sample(bytes: &[u8]) -> Option { + let mut decoder = JpegDecoder::new(Cursor::new(bytes)); + let pixels = decoder.decode().ok()?; + let info = decoder.info()?; + let width = usize::from(info.width); + let height = usize::from(info.height); + if width.saturating_mul(height) < min_pixels_for_guard() { + return None; + } + match info.pixel_format { + PixelFormat::L8 => sample_luma_frame(width, height, &pixels), + PixelFormat::RGB24 => sample_rgb_frame(width, height, &pixels), + PixelFormat::CMYK32 => sample_cmyk_frame(width, height, &pixels), + _ => None, + } +} + +fn row_bounds(frame: &SampledFrame) -> std::ops::Range { + let start = frame.height / 6; + let end = (frame.height.saturating_mul(19) / 20).max(start + 1); + start..end.min(frame.height) +} + +fn row_stats(frame: &SampledFrame, row: usize) -> (f64, f64) { + let start = row.saturating_mul(frame.width); + let end = start + frame.width; + let mut sum = 0f64; + let mut sum_sq = 0f64; + for pixel in &frame.pixels[start..end] { + let value = f64::from(pixel.luma); + sum += value; + sum_sq += value * value; + } + let count = frame.width.max(1) as f64; + let mean = sum / count; + let variance = (sum_sq / count - mean * mean).max(0.0); + (mean, variance.sqrt()) +} + +fn flat_block_reason(frame: &SampledFrame) -> Option { + let threshold = flat_stddev_threshold(); + let min_run = (frame.height.saturating_mul(flat_run_pct_threshold()) / 100).max(2); + let mut best_start = 0usize; + let mut best_len = 0usize; + let mut current_start = 0usize; + let mut current_len = 0usize; + for row in row_bounds(frame) { + let (mean, stddev) = row_stats(frame, row); + let mid_gray = (58.0..=205.0).contains(&mean); + if mid_gray && stddev <= threshold { + if current_len == 0 { + current_start = row; + } + current_len += 1; + if current_len > best_len { + best_len = current_len; + best_start = current_start; + } + } else { + current_len = 0; + } + } + if best_len >= min_run { + return Some(MjpegVisualArtifactReason::FlatBlock { + start_row_pct: pct(best_start, frame.height), + end_row_pct: pct(best_start + best_len, frame.height), + run_pct: pct(best_len, frame.height), + }); + } + None +} + +fn seam_reason(frame: &SampledFrame) -> Option { + 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; + + 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 { + 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 + { + 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 { + 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 { + 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 { + 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, + }); + } + best_luma.map(|(row, coverage, delta)| MjpegVisualArtifactReason::HorizontalTileSeam { + row_pct: pct(row, frame.height), + coverage_pct: coverage.min(100) as u8, + delta, + }) +} + +fn inspect_sampled_frame_for_artifacts(frame: &SampledFrame) -> Option { + flat_block_reason(frame).or_else(|| seam_reason(frame)) +} + +/// Decode one MJPEG image and classify visible UVC-bound corruption. +/// +/// Inputs: compressed MJPEG bytes. Output: a concrete artifact reason when the +/// decoded pixels contain a gray slab, a full-width tile seam, or a chroma-tile +/// seam. Why: this catches the real field failures where JPEG syntax and byte +/// size are healthy but the picture is visibly torn before it reaches the RCT. +pub(super) fn mjpeg_visual_artifact_reason(bytes: &[u8]) -> Option { + if !mjpeg_pixel_guard_enabled() { + return None; + } + let frame = decode_jpeg_sample(bytes)?; + inspect_sampled_frame_for_artifacts(&frame) +} + +#[cfg(test)] +pub(super) fn sampled_rgb_frame_for_test( + width: usize, + height: usize, + pixels: &[u8], +) -> Option { + let frame = sample_rgb_frame(width, height, pixels)?; + inspect_sampled_frame_for_artifacts(&frame) +} diff --git a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs index 9c254d1..561600c 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs @@ -194,6 +194,170 @@ fn direct_mjpeg_guard_rejects_incomplete_and_obvious_bad_frames_after_baseline() ); } +fn smooth_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 { + pixels.push(((x * 180 / width) + 32) as u8); + pixels.push(((y * 150 / height) + 48) as u8); + pixels.push((((x + y) * 120 / (width + height)) + 64) as u8); + } + } + pixels +} + +#[test] +fn pixel_guard_accepts_smooth_frames() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH", Some("160")), + ], + || { + let frame = smooth_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( + [ + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_STDDEV", Some("6")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_FLAT_RUN_PCT", Some("16")), + ], + || { + let width = 320; + let height = 180; + let mut frame = smooth_rgb_frame(width, height); + for y in height / 2..height { + for x in 0..width { + let offset = (y * width + x) * 3; + frame[offset] = 128; + frame[offset + 1] = 128; + frame[offset + 2] = 128; + } + } + assert!(matches!( + super::mjpeg_visual_guard::sampled_rgb_frame_for_test(width, height, &frame), + Some(super::mjpeg_visual_guard::MjpegVisualArtifactReason::FlatBlock { .. }) + )); + }, + ); +} + +#[test] +fn pixel_guard_rejects_rectangular_tile_seams() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA", Some("28")), + ( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT", + Some("35"), + ), + ], + || { + let width = 320; + let height = 180; + let mut frame = smooth_rgb_frame(width, height); + for y in height / 2..height { + for x in 0..width { + let offset = (y * width + x) * 3; + if x < width / 2 { + frame[offset] = 42; + frame[offset + 1] = 96; + frame[offset + 2] = 124; + } else { + frame[offset] = 220; + frame[offset + 1] = 178; + frame[offset + 2] = 132; + } + } + } + assert!(matches!( + super::mjpeg_visual_guard::sampled_rgb_frame_for_test(width, height, &frame), + Some( + super::mjpeg_visual_guard::MjpegVisualArtifactReason::HorizontalTileSeam { .. } + | super::mjpeg_visual_guard::MjpegVisualArtifactReason::PartialTileSeam { .. } + | super::mjpeg_visual_guard::MjpegVisualArtifactReason::ChromaTileSeam { .. } + ) + )); + }, + ); +} + +#[test] +fn pixel_guard_rejects_partial_width_tile_seams() { + 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"), + ), + ], + || { + let width = 320; + let height = 180; + let mut frame = smooth_rgb_frame(width, height); + for y in height / 2..height { + for x in width / 4..(width / 4 + width / 3) { + let offset = (y * width + x) * 3; + frame[offset] = 28; + frame[offset + 1] = 82; + frame[offset + 2] = 130; + } + } + assert!(matches!( + super::mjpeg_visual_guard::sampled_rgb_frame_for_test(width, height, &frame), + Some(super::mjpeg_visual_guard::MjpegVisualArtifactReason::PartialTileSeam { .. }) + )); + }, + ); +} + +#[test] +fn pixel_guard_rejects_chroma_shifted_tile_seams() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD", Some("1")), + ("LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_DELTA", Some("18")), + ( + "LESAVKA_UVC_MJPEG_PIXEL_GUARD_CHROMA_COVERAGE_PCT", + Some("30"), + ), + ], + || { + let width = 320; + let height = 180; + let mut frame = smooth_rgb_frame(width, height); + for y in height / 2..height { + for x in 0..width { + let offset = (y * width + x) * 3; + frame[offset] = 120u8.saturating_add((x % 90) as u8); + frame[offset + 1] = 20u8.saturating_add((y % 36) as u8); + frame[offset + 2] = 210u8.saturating_sub((x % 100) as u8); + } + } + assert!(matches!( + super::mjpeg_visual_guard::sampled_rgb_frame_for_test(width, height, &frame), + Some(super::mjpeg_visual_guard::MjpegVisualArtifactReason::ChromaTileSeam { .. }) + )); + }, + ); +} + #[test] fn direct_mjpeg_guard_reports_oversize_and_profile_mismatch_when_configured() { fn jpeg_with_sof(width: u16, height: u16, payload: &[u8]) -> Vec {