fix(server): freeze tiled UVC-bound MJPEG artifacts

This commit is contained in:
Brad Stein 2026-05-18 21:05:11 -03:00
parent 221240229c
commit 2df5eb872b
8 changed files with 674 additions and 7 deletions

13
Cargo.lock generated
View File

@ -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",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.23.1"
version = "0.23.2"
edition = "2024"
[dependencies]

View File

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

View File

@ -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 |

View File

@ -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"] }

View File

@ -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 });
}

View File

@ -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<PixelSample>,
}
/// 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<SampledFrame> {
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<SampledFrame> {
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<SampledFrame> {
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<SampledFrame> {
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<usize> {
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<MjpegVisualArtifactReason> {
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<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;
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<MjpegVisualArtifactReason> {
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<MjpegVisualArtifactReason> {
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<MjpegVisualArtifactReason> {
let frame = sample_rgb_frame(width, height, pixels)?;
inspect_sampled_frame_for_artifacts(&frame)
}

View File

@ -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<u8> {
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<u8> {