fix: allow synthetic grid frames through UVC guard
This commit is contained in:
parent
5f2c2efa13
commit
cf1c1fa47d
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.25.1"
|
version = "0.25.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1692,7 +1692,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.25.1"
|
version = "0.25.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1704,7 +1704,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.25.1"
|
version = "0.25.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.25.1"
|
version = "0.25.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.25.1"
|
version = "0.25.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.25.1"
|
version = "0.25.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -339,6 +339,9 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
let mut best_luma: Option<(usize, usize, u16)> = None;
|
let mut best_luma: Option<(usize, usize, u16)> = None;
|
||||||
let mut best_partial: Option<(usize, 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 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) {
|
for row in row_bounds(frame).skip(1) {
|
||||||
let prev_start = (row - 1) * frame.width;
|
let prev_start = (row - 1) * frame.width;
|
||||||
@ -385,6 +388,7 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
let chroma_avg = (chroma_total / frame.width.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 {
|
if luma_coverage >= luma_coverage_threshold && luma_avg >= luma_threshold as u16 {
|
||||||
|
luma_candidate_rows.push(row);
|
||||||
match best_luma {
|
match best_luma {
|
||||||
Some((_, best_coverage, best_avg))
|
Some((_, best_coverage, best_avg))
|
||||||
if best_coverage > luma_coverage
|
if best_coverage > luma_coverage
|
||||||
@ -397,6 +401,7 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
&& partial_luma_run_pct >= partial_run_threshold
|
&& partial_luma_run_pct >= partial_run_threshold
|
||||||
&& partial_luma_avg >= partial_luma_threshold as u16
|
&& partial_luma_avg >= partial_luma_threshold as u16
|
||||||
{
|
{
|
||||||
|
partial_candidate_rows.push(row);
|
||||||
match best_partial {
|
match best_partial {
|
||||||
Some((_, best_coverage, best_run, best_avg))
|
Some((_, best_coverage, best_run, best_avg))
|
||||||
if best_coverage > partial_luma_coverage
|
if best_coverage > partial_luma_coverage
|
||||||
@ -415,6 +420,7 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if chroma_coverage >= chroma_coverage_threshold && chroma_avg >= chroma_threshold as u16 {
|
if chroma_coverage >= chroma_coverage_threshold && chroma_avg >= chroma_threshold as u16 {
|
||||||
|
chroma_candidate_rows.push(row);
|
||||||
match best_chroma {
|
match best_chroma {
|
||||||
Some((_, best_coverage, best_avg))
|
Some((_, best_coverage, best_avg))
|
||||||
if best_coverage > chroma_coverage
|
if best_coverage > chroma_coverage
|
||||||
@ -425,6 +431,9 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some((row, coverage, delta)) = best_chroma {
|
if let Some((row, coverage, delta)) = best_chroma {
|
||||||
|
if regular_texture_seam_rows(frame, &chroma_candidate_rows) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
return Some(MjpegVisualArtifactReason::ChromaTileSeam {
|
return Some(MjpegVisualArtifactReason::ChromaTileSeam {
|
||||||
row_pct: pct(row, frame.height),
|
row_pct: pct(row, frame.height),
|
||||||
coverage_pct: coverage.min(100) as u8,
|
coverage_pct: coverage.min(100) as u8,
|
||||||
@ -432,6 +441,9 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some((row, coverage, run_pct, delta)) = best_partial {
|
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 {
|
return Some(MjpegVisualArtifactReason::PartialTileSeam {
|
||||||
row_pct: pct(row, frame.height),
|
row_pct: pct(row, frame.height),
|
||||||
coverage_pct: coverage.min(100) as u8,
|
coverage_pct: coverage.min(100) as u8,
|
||||||
@ -439,6 +451,9 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
delta,
|
delta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if regular_texture_seam_rows(frame, &luma_candidate_rows) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
best_luma.map(|(row, coverage, delta)| MjpegVisualArtifactReason::HorizontalTileSeam {
|
best_luma.map(|(row, coverage, delta)| MjpegVisualArtifactReason::HorizontalTileSeam {
|
||||||
row_pct: pct(row, frame.height),
|
row_pct: pct(row, frame.height),
|
||||||
coverage_pct: coverage.min(100) as u8,
|
coverage_pct: coverage.min(100) as u8,
|
||||||
@ -446,6 +461,52 @@ fn seam_reason(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<usize> = 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<MjpegVisualArtifactReason> {
|
fn inspect_sampled_frame_for_artifacts(frame: &SampledFrame) -> Option<MjpegVisualArtifactReason> {
|
||||||
flat_block_reason(frame).or_else(|| seam_reason(frame))
|
flat_block_reason(frame).or_else(|| seam_reason(frame))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,6 +206,20 @@ fn smooth_rgb_frame(width: usize, height: usize) -> Vec<u8> {
|
|||||||
pixels
|
pixels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn checker_grid_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 {
|
||||||
|
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]
|
#[test]
|
||||||
fn pixel_guard_accepts_smooth_frames() {
|
fn pixel_guard_accepts_smooth_frames() {
|
||||||
temp_env::with_vars(
|
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]
|
#[test]
|
||||||
fn pixel_guard_rejects_flat_lower_block_frames() {
|
fn pixel_guard_rejects_flat_lower_block_frames() {
|
||||||
temp_env::with_vars(
|
temp_env::with_vars(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user