probe: separate visual corruption from cadence events

This commit is contained in:
Brad Stein 2026-05-17 11:23:23 -03:00
parent 50891d7b7d
commit a32a083711
6 changed files with 91 additions and 36 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.22.53" version = "0.22.54"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.22.53" version = "0.22.54"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.22.53" version = "0.22.54"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

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

View File

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

View File

@ -23,6 +23,7 @@ DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85
DEFAULT_UVC_MAX_PACKET = 1024 DEFAULT_UVC_MAX_PACKET = 1024
MARKER_BITS = 32 MARKER_BITS = 32
MARKER_COLUMNS = 16 MARKER_COLUMNS = 16
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
def parse_args() -> argparse.Namespace: 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 frame[row + x] = value
def synthetic_gray(width: int, height: int, sequence: int) -> bytes: def synthetic_base_luma(width: int, height: int, sequence: int, x: int, y: int) -> int:
data = bytearray(width * height)
safe_width = max(width, 1) safe_width = max(width, 1)
safe_height = max(height, 1) safe_height = max(height, 1)
moving_width = min(max(width // 10, 32), safe_width) 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 center_y = height // 2
block_w = max(width // 24, 24) block_w = max(width // 24, 24)
block_h = max(height // 18, 18) 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): for y in range(height):
row = y * width row = y * width
for x in range(width): for x in range(width):
base = 44 + (x * 72 // safe_width) + (y * 52 // safe_height) + ((sequence * 3) % 28) data[row + x] = synthetic_luma(width, height, sequence, x, y)
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)
return bytes(data) return bytes(data)
@ -578,13 +612,13 @@ def decode_sequence(frame: bytes, width: int, height: int) -> tuple[int | None,
return value, uncertain 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 total = 0
count = 0 count = 0
for y in range(y0, y1, y_step): for y in range(y0, y1, y_step):
row = y * width row = y * width
for x in range(0, width, x_step): 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 count += 1
return total / max(1, count) 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) 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) x0 = max(0, -shift)
x1 = min(width, width - shift) x1 = min(width, width - shift)
if x0 >= x1: 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): for y in range(y0, height, args.y_step):
row = y * width row = y * width
for x in range(x0, x1, args.x_step): 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 count += 1
return total / max(1, count) 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]: 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, expected, width, height, 0, args) zero = shifted_expected_delta(frame, width, height, sequence, 0, args)
best = zero best = zero
best_shift = 0 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]: 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: if candidate < best:
best = candidate best = candidate
best_shift = shift best_shift = shift
@ -650,15 +684,14 @@ def analyze_frame(
previous_seq: int | None, previous_seq: int | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
sequence, uncertain_bits = decode_sequence(frame, width, height) 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 upper_mae = lower_mae = total_mae = 0.0
shift_pixels = 0 shift_pixels = 0
shift_zero_delta = shift_best_delta = shift_improvement = 0.0 shift_zero_delta = shift_best_delta = shift_improvement = 0.0
if expected is not None: if sequence is not None:
upper_mae = sampled_abs_delta(frame, expected, width, 0, height // 2, args.x_step, args.y_step) upper_mae = sampled_abs_delta_expected(frame, width, height, sequence, 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) lower_mae = sampled_abs_delta_expected(frame, width, height, sequence, 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) 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, expected, width, height, args) 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_count = max(8, args.bands)
band_h = max(1, height // band_count) band_h = max(1, height // band_count)
@ -686,7 +719,7 @@ def analyze_frame(
reasons.append("frame_gap") reasons.append("frame_gap")
elif sequence < previous_seq: elif sequence < previous_seq:
reasons.append("frame_backwards") 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): 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") 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): 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") reasons.append("black_or_gray_slab")
if shift_pixels and shift_zero_delta > args.shift_threshold and shift_improvement > args.shift_improvement: if shift_pixels and shift_zero_delta > args.shift_threshold and shift_improvement > args.shift_improvement:
reasons.append("horizontal_shift") 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 { return {
"suspicious": bool(reasons), "suspicious": bool(reasons),
"visual_suspicious": bool(visual_reasons),
"reasons": reasons, "reasons": reasons,
"visual_reasons": visual_reasons,
"cadence_reasons": cadence_reasons,
"decoded_sequence": sequence, "decoded_sequence": sequence,
"marker_uncertain_bits": uncertain_bits, "marker_uncertain_bits": uncertain_bits,
"upper_mae": round(upper_mae, 3), "upper_mae": round(upper_mae, 3),
@ -731,17 +769,20 @@ def run_capture(args: argparse.Namespace) -> int:
ffmpeg_rc: int | None = None ffmpeg_rc: int | None = None
frame_index = 0 frame_index = 0
suspicious_count = 0 suspicious_count = 0
visual_suspicious_count = 0
reference_artifacts = 0 reference_artifacts = 0
suspicious_artifacts = 0 suspicious_artifacts = 0
previous_seq: int | None = None previous_seq: int | None = None
decoded_frames = 0 decoded_frames = 0
reason_counts: collections.Counter[str] = collections.Counter() 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() sequence_counts: collections.Counter[int] = collections.Counter()
max_total_mae = max_upper_mae = max_lower_mae = 0.0 max_total_mae = max_upper_mae = max_lower_mae = 0.0
worst: list[dict[str, Any]] = [] worst: list[dict[str, Any]] = []
def analyze_captured_frame(frame: bytes, elapsed_s: float, metrics: Any) -> None: 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 nonlocal previous_seq, decoded_frames, max_total_mae, max_upper_mae, max_lower_mae, worst
frame_index += 1 frame_index += 1
result = analyze_frame(frame, capture_width, capture_height, args, previous_seq) 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"]: if result["suspicious"]:
suspicious_count += 1 suspicious_count += 1
reason_counts.update(result["reasons"]) reason_counts.update(result["reasons"])
visual_reason_counts.update(result["visual_reasons"])
cadence_reason_counts.update(result["cadence_reasons"])
worst.append(result) worst.append(result)
worst = sorted(worst, key=lambda item: (item["lower_mae"], item["total_mae"]), reverse=True)[:30] 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}" 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) write_pgm(artifact_dir / f"suspicious_{frame_index:06d}_{seq_label}.pgm", frame, capture_width, capture_height)
if decoded_seq is not None: 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, "decoded_pct": round(decoded_frames / frame_index * 100.0, 3) if frame_index else 0.0,
"suspicious_frames": suspicious_count, "suspicious_frames": suspicious_count,
"suspicious_pct": round(suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0, "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), "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)), "decoded_sequence_counts": dict(sequence_counts.most_common(12)),
"max_total_mae": round(max_total_mae, 3), "max_total_mae": round(max_total_mae, 3),
"max_upper_mae": round(max_upper_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"frames: {summary['frames']} ({summary['fps_observed']} fps observed)",
f"decoded markers: {summary['decoded_frames']} ({summary['decoded_pct']}%)", f"decoded markers: {summary['decoded_frames']} ({summary['decoded_pct']}%)",
f"suspicious: {summary['suspicious_frames']} ({summary['suspicious_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"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"max mae: total={summary['max_total_mae']} upper={summary['max_upper_mae']} lower={summary['max_lower_mae']}",
f"artifacts: {summary['artifact_dir']}", f"artifacts: {summary['artifact_dir']}",
"", "",

View File

@ -16,7 +16,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.22.53" version = "0.22.54"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -60,6 +60,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
"capture_mode", "capture_mode",
"raw_capture_bytes", "raw_capture_bytes",
"analysis_duration_s", "analysis_duration_s",
"visual_suspicious",
"visual_reason_counts",
"cadence_reason_counts",
"diagnosis", "diagnosis",
"encoded_oversize_frames", "encoded_oversize_frames",
"sent_frames", "sent_frames",