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

View File

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

View File

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

View File

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

View File

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

View File

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