From a32a08371115b3fb513b33e0f1d93eaa81ad80c8 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 17 May 2026 11:23:23 -0300 Subject: [PATCH] probe: separate visual corruption from cadence events --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/manual/run_synthetic_rct_uvc_probe.py | 112 +++++++++++++----- server/Cargo.toml | 2 +- ...synthetic_rct_uvc_probe_manual_contract.rs | 3 + 6 files changed, 91 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01b30ac..0e2dc20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.53" +version = "0.22.54" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.53" +version = "0.22.54" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.53" +version = "0.22.54" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 604f141..98ea340 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.53" +version = "0.22.54" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 1f4ffd8..d2f5ed9 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.53" +version = "0.22.54" edition = "2024" build = "build.rs" diff --git a/scripts/manual/run_synthetic_rct_uvc_probe.py b/scripts/manual/run_synthetic_rct_uvc_probe.py index 2b3674b..4cdcd8f 100755 --- a/scripts/manual/run_synthetic_rct_uvc_probe.py +++ b/scripts/manual/run_synthetic_rct_uvc_probe.py @@ -23,6 +23,7 @@ DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85 DEFAULT_UVC_MAX_PACKET = 1024 MARKER_BITS = 32 MARKER_COLUMNS = 16 +CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"} def parse_args() -> argparse.Namespace: @@ -499,8 +500,7 @@ def fill_rect(frame: bytearray, width: int, height: int, x0: int, y0: int, w: in frame[row + x] = value -def synthetic_gray(width: int, height: int, sequence: int) -> bytes: - data = bytearray(width * height) +def synthetic_base_luma(width: int, height: int, sequence: int, x: int, y: int) -> int: safe_width = max(width, 1) safe_height = max(height, 1) moving_width = min(max(width // 10, 32), safe_width) @@ -509,21 +509,55 @@ def synthetic_gray(width: int, height: int, sequence: int) -> bytes: center_y = height // 2 block_w = max(width // 24, 24) block_h = max(height // 18, 18) + base = 44 + (x * 72 // safe_width) + (y * 52 // safe_height) + ((sequence * 3) % 28) + checker = 30 if (((x // block_w) + (y // block_h) + (sequence // 5)) & 1) == 0 else 0 + value = min(238, base + checker) + moving = (x + safe_width - moving_offset) % safe_width + if moving < moving_width: + value = min(255, 220 - (y * 54 // safe_height)) + elif moving < moving_width + 4: + value = 28 + if abs(x - center_x) < width // 9 and abs(y - center_y) < height // 12: + value = 255 - value // 2 + return value + + +def synthetic_marker_luma(width: int, height: int, sequence: int, x: int, y: int) -> int | None: + cell = marker_cell(width, height) + rows = (MARKER_BITS + MARKER_COLUMNS - 1) // MARKER_COLUMNS + if width < (MARKER_COLUMNS + 4) * cell or height < (rows + 4) * cell: + return None + marker_x = 2 * cell + marker_y = 2 * cell + if cell <= x < (MARKER_COLUMNS + 3) * cell and cell <= y < (rows + 3) * cell: + value = 32 + if marker_x - cell <= x < marker_x and marker_y - cell <= y < marker_y: + value = 255 + elif marker_x + MARKER_COLUMNS * cell <= x < marker_x + (MARKER_COLUMNS + 1) * cell and marker_y - cell <= y < marker_y: + value = 0 + elif marker_x <= x < marker_x + MARKER_COLUMNS * cell and marker_y <= y < marker_y + rows * cell: + col = (x - marker_x) // cell + row = (y - marker_y) // cell + bit = row * MARKER_COLUMNS + col + if bit < MARKER_BITS: + value = 255 if ((sequence >> bit) & 1) else 0 + return value + return None + + +def synthetic_luma(width: int, height: int, sequence: int, x: int, y: int) -> int: + marker = synthetic_marker_luma(width, height, sequence, x, y) + if marker is not None: + return marker + return synthetic_base_luma(width, height, sequence, x, y) + + +def synthetic_gray(width: int, height: int, sequence: int) -> bytes: + data = bytearray(width * height) for y in range(height): row = y * width for x in range(width): - base = 44 + (x * 72 // safe_width) + (y * 52 // safe_height) + ((sequence * 3) % 28) - checker = 30 if (((x // block_w) + (y // block_h) + (sequence // 5)) & 1) == 0 else 0 - value = min(238, base + checker) - moving = (x + safe_width - moving_offset) % safe_width - if moving < moving_width: - value = min(255, 220 - (y * 54 // safe_height)) - elif moving < moving_width + 4: - value = 28 - if abs(x - center_x) < width // 9 and abs(y - center_y) < height // 12: - value = 255 - value // 2 - data[row + x] = value - draw_marker(data, width, height, sequence) + data[row + x] = synthetic_luma(width, height, sequence, x, y) return bytes(data) @@ -578,13 +612,13 @@ def decode_sequence(frame: bytes, width: int, height: int) -> tuple[int | None, return value, uncertain -def sampled_abs_delta(a: bytes, b: bytes, width: int, y0: int, y1: int, x_step: int, y_step: int) -> float: +def sampled_abs_delta_expected(frame: bytes, width: int, height: int, sequence: int, y0: int, y1: int, x_step: int, y_step: int) -> float: total = 0 count = 0 for y in range(y0, y1, y_step): row = y * width for x in range(0, width, x_step): - total += abs(a[row + x] - b[row + x]) + total += abs(frame[row + x] - synthetic_luma(width, height, sequence, x, y)) count += 1 return total / max(1, count) @@ -604,7 +638,7 @@ def band_stats(frame: bytes, width: int, y0: int, y1: int, x_step: int, y_step: return mean, max(0.0, total2 / max(1, count) - mean * mean) -def shifted_expected_delta(frame: bytes, expected: bytes, width: int, height: int, shift: int, args: argparse.Namespace) -> float: +def shifted_expected_delta(frame: bytes, width: int, height: int, sequence: int, shift: int, args: argparse.Namespace) -> float: x0 = max(0, -shift) x1 = min(width, width - shift) if x0 >= x1: @@ -615,17 +649,17 @@ def shifted_expected_delta(frame: bytes, expected: bytes, width: int, height: in for y in range(y0, height, args.y_step): row = y * width for x in range(x0, x1, args.x_step): - total += abs(frame[row + x] - expected[row + x + shift]) + total += abs(frame[row + x] - synthetic_luma(width, height, sequence, x + shift, y)) count += 1 return total / max(1, count) -def best_expected_shift(frame: bytes, expected: bytes, width: int, height: int, args: argparse.Namespace) -> tuple[int, float, float, float]: - zero = shifted_expected_delta(frame, expected, width, height, 0, args) +def best_expected_shift(frame: bytes, width: int, height: int, sequence: int, args: argparse.Namespace) -> tuple[int, float, float, float]: + zero = shifted_expected_delta(frame, width, height, sequence, 0, args) best = zero best_shift = 0 for shift in [-128, -96, -80, -64, -48, -32, -24, -16, -12, -8, 8, 12, 16, 24, 32, 48, 64, 80, 96, 128]: - candidate = shifted_expected_delta(frame, expected, width, height, shift, args) + candidate = shifted_expected_delta(frame, width, height, sequence, shift, args) if candidate < best: best = candidate best_shift = shift @@ -650,15 +684,14 @@ def analyze_frame( previous_seq: int | None, ) -> dict[str, Any]: sequence, uncertain_bits = decode_sequence(frame, width, height) - expected = synthetic_gray(width, height, sequence or 0) if sequence is not None else None upper_mae = lower_mae = total_mae = 0.0 shift_pixels = 0 shift_zero_delta = shift_best_delta = shift_improvement = 0.0 - if expected is not None: - upper_mae = sampled_abs_delta(frame, expected, width, 0, height // 2, args.x_step, args.y_step) - lower_mae = sampled_abs_delta(frame, expected, width, height // 2, height, args.x_step, args.y_step) - total_mae = sampled_abs_delta(frame, expected, width, 0, height, args.x_step, args.y_step) - shift_pixels, shift_zero_delta, shift_best_delta, shift_improvement = best_expected_shift(frame, expected, width, height, args) + if sequence is not None: + upper_mae = sampled_abs_delta_expected(frame, width, height, sequence, 0, height // 2, args.x_step, args.y_step) + lower_mae = sampled_abs_delta_expected(frame, width, height, sequence, height // 2, height, args.x_step, args.y_step) + total_mae = sampled_abs_delta_expected(frame, width, height, sequence, 0, height, args.x_step, args.y_step) + shift_pixels, shift_zero_delta, shift_best_delta, shift_improvement = best_expected_shift(frame, width, height, sequence, args) band_count = max(8, args.bands) band_h = max(1, height // band_count) @@ -686,7 +719,7 @@ def analyze_frame( reasons.append("frame_gap") elif sequence < previous_seq: reasons.append("frame_backwards") - if expected is not None: + if sequence is not None: if lower_mae > args.lower_mae_threshold and lower_mae > max(upper_mae * args.lower_skew_ratio, args.lower_mae_threshold): reasons.append("lower_half_tear") if total_mae > args.mae_threshold and lower_mae <= max(upper_mae * args.lower_skew_ratio, args.lower_mae_threshold): @@ -695,9 +728,14 @@ def analyze_frame( reasons.append("black_or_gray_slab") if shift_pixels and shift_zero_delta > args.shift_threshold and shift_improvement > args.shift_improvement: reasons.append("horizontal_shift") + visual_reasons = [reason for reason in reasons if reason not in CADENCE_REASONS] + cadence_reasons = [reason for reason in reasons if reason in CADENCE_REASONS] return { "suspicious": bool(reasons), + "visual_suspicious": bool(visual_reasons), "reasons": reasons, + "visual_reasons": visual_reasons, + "cadence_reasons": cadence_reasons, "decoded_sequence": sequence, "marker_uncertain_bits": uncertain_bits, "upper_mae": round(upper_mae, 3), @@ -731,17 +769,20 @@ def run_capture(args: argparse.Namespace) -> int: ffmpeg_rc: int | None = None frame_index = 0 suspicious_count = 0 + visual_suspicious_count = 0 reference_artifacts = 0 suspicious_artifacts = 0 previous_seq: int | None = None decoded_frames = 0 reason_counts: collections.Counter[str] = collections.Counter() + visual_reason_counts: collections.Counter[str] = collections.Counter() + cadence_reason_counts: collections.Counter[str] = collections.Counter() sequence_counts: collections.Counter[int] = collections.Counter() max_total_mae = max_upper_mae = max_lower_mae = 0.0 worst: list[dict[str, Any]] = [] def analyze_captured_frame(frame: bytes, elapsed_s: float, metrics: Any) -> None: - nonlocal frame_index, suspicious_count, reference_artifacts, suspicious_artifacts + nonlocal frame_index, suspicious_count, visual_suspicious_count, reference_artifacts, suspicious_artifacts nonlocal previous_seq, decoded_frames, max_total_mae, max_upper_mae, max_lower_mae, worst frame_index += 1 result = analyze_frame(frame, capture_width, capture_height, args, previous_seq) @@ -757,9 +798,13 @@ def run_capture(args: argparse.Namespace) -> int: if result["suspicious"]: suspicious_count += 1 reason_counts.update(result["reasons"]) + visual_reason_counts.update(result["visual_reasons"]) + cadence_reason_counts.update(result["cadence_reasons"]) worst.append(result) worst = sorted(worst, key=lambda item: (item["lower_mae"], item["total_mae"]), reverse=True)[:30] - if suspicious_artifacts < args.max_suspicious_artifacts: + if result["visual_suspicious"]: + visual_suspicious_count += 1 + if result["visual_suspicious"] and suspicious_artifacts < args.max_suspicious_artifacts: seq_label = "unknown" if decoded_seq is None else f"seq{decoded_seq:08d}" write_pgm(artifact_dir / f"suspicious_{frame_index:06d}_{seq_label}.pgm", frame, capture_width, capture_height) if decoded_seq is not None: @@ -850,7 +895,11 @@ def run_capture(args: argparse.Namespace) -> int: "decoded_pct": round(decoded_frames / frame_index * 100.0, 3) if frame_index else 0.0, "suspicious_frames": suspicious_count, "suspicious_pct": round(suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0, + "visual_suspicious_frames": visual_suspicious_count, + "visual_suspicious_pct": round(visual_suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0, "reason_counts": dict(reason_counts), + "visual_reason_counts": dict(visual_reason_counts), + "cadence_reason_counts": dict(cadence_reason_counts), "decoded_sequence_counts": dict(sequence_counts.most_common(12)), "max_total_mae": round(max_total_mae, 3), "max_upper_mae": round(max_upper_mae, 3), @@ -878,7 +927,10 @@ def format_summary(summary: dict[str, Any]) -> str: f"frames: {summary['frames']} ({summary['fps_observed']} fps observed)", f"decoded markers: {summary['decoded_frames']} ({summary['decoded_pct']}%)", f"suspicious: {summary['suspicious_frames']} ({summary['suspicious_pct']}%)", + f"visual suspicious: {summary['visual_suspicious_frames']} ({summary['visual_suspicious_pct']}%)", f"reasons: {summary['reason_counts']}", + f"visual reasons: {summary['visual_reason_counts']}", + f"cadence reasons: {summary['cadence_reason_counts']}", f"max mae: total={summary['max_total_mae']} upper={summary['max_upper_mae']} lower={summary['max_lower_mae']}", f"artifacts: {summary['artifact_dir']}", "", diff --git a/server/Cargo.toml b/server/Cargo.toml index 7b851e7..d436897 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.53" +version = "0.22.54" edition = "2024" autobins = false diff --git a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs index 077408a..efe7f9e 100644 --- a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs +++ b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs @@ -60,6 +60,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "capture_mode", "raw_capture_bytes", "analysis_duration_s", + "visual_suspicious", + "visual_reason_counts", + "cadence_reason_counts", "diagnosis", "encoded_oversize_frames", "sent_frames",