probe: separate visual corruption from cadence events
This commit is contained in:
parent
50891d7b7d
commit
a32a083711
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.53"
|
||||
version = "0.22.54"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.53"
|
||||
version = "0.22.54"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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']}",
|
||||
"",
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.53"
|
||||
version = "0.22.54"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user