fix(server): freeze tiled UVC-bound MJPEG artifacts
This commit is contained in:
parent
221240229c
commit
2df5eb872b
13
Cargo.lock
generated
13
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.23.1"
|
||||
version = "0.23.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.23.1"
|
||||
version = "0.23.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
475
server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs
Normal file
475
server/src/video_sinks/hevc_mjpeg_guard/mjpeg_visual_guard.rs
Normal 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)
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user