test(uvc): catch flat-frame receiver freezes

This commit is contained in:
Brad Stein 2026-08-13 04:26:50 -03:00
parent 99077a4527
commit 641e116d52
7 changed files with 86 additions and 13 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.27.12" version = "0.27.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.27.12" version = "0.27.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.27.12" version = "0.27.13"
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.27.12" version = "0.27.13"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

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

View File

@ -338,6 +338,12 @@ slabs and lower-frame tears fail independently from motion/blur flags that need
human review; incomplete duration, decoder failure, timeouts, and structural human review; incomplete duration, decoder failure, timeouts, and structural
corruption also fail instead of producing an easy success. corruption also fail instead of producing an easy success.
Release 0.27.13 closes the remaining real-video detector blind spot exposed by
the preserved 310-second receiver failure. A sudden whole-frame black/grey
collapse and an exact-frame replay lasting two seconds are now hard failures.
The historical Tethys recording fails at its known `103.467s` collapse while
the simultaneous clean Theia spool control reports zero hard corruption.
## 7. The Resolved Downstream Video Failure ## 7. The Resolved Downstream Video Failure
The current blank downstream feeds fail before transport or decoding. The current blank downstream feeds fail before transport or decoding.
@ -414,7 +420,7 @@ The safe completion sequence for this incident is:
6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the 6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the
already-attached USB gadget unless a controlled rebuild is explicitly already-attached USB gadget unless a controlled rebuild is explicitly
required. required.
7. Confirm Theia reports server version `0.27.12`, the pushed release revision, 7. Confirm Theia reports server version `0.27.13`, the pushed release revision,
direct MJPEG normalizer timeout `0`, and a coherent UVC contract. direct MJPEG normalizer timeout `0`, and a coherent UVC contract.
8. Open both downstream RPCs and prove that each emits changing, decodable H.264 8. Open both downstream RPCs and prove that each emits changing, decodable H.264
frames. frames.
@ -432,7 +438,7 @@ hardware contract is repeatable. The remaining work falls into five groups.
### A. Install And Version Parity ### A. Install And Version Parity
- Push and deploy `0.27.12` through the client/server install scripts. - Push and deploy `0.27.13` through the client/server install scripts.
- Confirm client/server version and revision in every hardware probe artifact. - Confirm client/server version and revision in every hardware probe artifact.
- Eliminate the current state where a fixed client talks to an unfixed server. - Eliminate the current state where a fixed client talks to an unfixed server.
@ -498,6 +504,6 @@ host repair:
9. disconnect/reconnect and device changes recover without stale media; and 9. disconnect/reconnect and device changes recover without stale media; and
10. diagnostics identify the failed physical stage when any item breaks. 10. diagnostics identify the failed physical stage when any item breaks.
Until that sequence passes on the installed `0.27.12` client/server pair, the Until that sequence passes on the installed `0.27.13` client/server pair, the
current release should be described as a validated code correction awaiting current release should be described as a validated code correction awaiting
hardware deployment and end-to-end acceptance, not as a completed product fix. hardware deployment and end-to-end acceptance, not as a completed product fix.

View File

@ -16,7 +16,9 @@ import time
from typing import Any from typing import Any
DEFAULT_DEVICE_LABEL = "Lesavka Composite" DEFAULT_DEVICE_LABEL = "Lesavka Composite"
HARD_CORRUPTION_REASONS = frozenset({"lower_boundary_jump", "lower_flat_flash", "lower_slab"}) HARD_CORRUPTION_REASONS = frozenset(
{"lower_boundary_jump", "lower_flat_flash", "lower_slab", "global_flat_flash", "exact_freeze"}
)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@ -51,6 +53,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--blur-delta-threshold", type=float, default=6.0) parser.add_argument("--blur-delta-threshold", type=float, default=6.0)
parser.add_argument("--blur-var-ratio", type=float, default=0.45) parser.add_argument("--blur-var-ratio", type=float, default=0.45)
parser.add_argument("--change-threshold", type=float, default=1.0) parser.add_argument("--change-threshold", type=float, default=1.0)
parser.add_argument(
"--max-exact-repeat-seconds",
type=float,
default=2.0,
help="fail when identical decoded frames persist this long after capture starts",
)
parser.add_argument("--max-suspicious-artifacts", type=int, default=40) parser.add_argument("--max-suspicious-artifacts", type=int, default=40)
parser.add_argument("--max-reference-artifacts", type=int, default=12) parser.add_argument("--max-reference-artifacts", type=int, default=12)
parser.add_argument("--reference-every", type=int, default=900) parser.add_argument("--reference-every", type=int, default=900)
@ -150,6 +158,8 @@ def run_remote(args: argparse.Namespace) -> int:
str(args.blur_var_ratio), str(args.blur_var_ratio),
"--change-threshold", "--change-threshold",
str(args.change_threshold), str(args.change_threshold),
"--max-exact-repeat-seconds",
str(args.max_exact_repeat_seconds),
"--max-suspicious-artifacts", "--max-suspicious-artifacts",
str(args.max_suspicious_artifacts), str(args.max_suspicious_artifacts),
"--max-reference-artifacts", "--max-reference-artifacts",
@ -343,6 +353,19 @@ def analyze_frame(frame: bytes, previous: bytes | None, args: argparse.Namespace
means.append(mean) means.append(mean)
variances.append(variance) variances.append(variance)
global_mean = sum(means) / max(1, len(means))
global_variance = max(
0.0,
sum(variance + mean * mean for mean, variance in zip(means, variances, strict=True))
/ max(1, len(means))
- global_mean * global_mean,
)
previous_global_variance = 0.0
if previous is not None:
_, previous_global_variance = band_stats(
previous, width, 0, height, args.x_step, args.y_step
)
half = band_count // 2 half = band_count // 2
lower_flags = [var < args.flat_var for var in variances[half:]] lower_flags = [var < args.flat_var for var in variances[half:]]
lower_flat_pct = sum(lower_flags) / max(1, len(lower_flags)) lower_flat_pct = sum(lower_flags) / max(1, len(lower_flags))
@ -373,6 +396,11 @@ def analyze_frame(frame: bytes, previous: bytes | None, args: argparse.Namespace
) )
lower_flat_flash = lower_flat_pct >= 0.25 and temporal_lower_jump lower_flat_flash = lower_flat_pct >= 0.25 and temporal_lower_jump
lower_slab = lower_flat_run_pct >= 0.33 and max_lower_jump > args.jump_threshold and temporal_lower_jump lower_slab = lower_flat_run_pct >= 0.33 and max_lower_jump > args.jump_threshold and temporal_lower_jump
global_flat_flash = (
previous is not None
and global_variance < args.flat_var
and previous_global_variance > max(args.flat_var * 4.0, 72.0)
)
temporal_delta_spike = ( temporal_delta_spike = (
previous is not None previous is not None
and max_temporal_delta > args.tear_threshold and max_temporal_delta > args.tear_threshold
@ -398,6 +426,8 @@ def analyze_frame(frame: bytes, previous: bytes | None, args: argparse.Namespace
reasons.append("lower_flat_flash") reasons.append("lower_flat_flash")
if lower_slab: if lower_slab:
reasons.append("lower_slab") reasons.append("lower_slab")
if global_flat_flash:
reasons.append("global_flat_flash")
if temporal_delta_spike: if temporal_delta_spike:
reasons.append("temporal_delta_spike") reasons.append("temporal_delta_spike")
if horizontal_shift: if horizontal_shift:
@ -410,6 +440,7 @@ def analyze_frame(frame: bytes, previous: bytes | None, args: argparse.Namespace
or lower_boundary_jump or lower_boundary_jump
or lower_flat_flash or lower_flat_flash
or lower_slab or lower_slab
or global_flat_flash
or temporal_delta_spike or temporal_delta_spike
or horizontal_shift or horizontal_shift
or lower_blur_flash or lower_blur_flash
@ -432,9 +463,17 @@ def analyze_frame(frame: bytes, previous: bytes | None, args: argparse.Namespace
"shift_improvement": round(shift_improvement, 3), "shift_improvement": round(shift_improvement, 3),
"lower_variance_min": round(min(variances[half:] or [0.0]), 3), "lower_variance_min": round(min(variances[half:] or [0.0]), 3),
"lower_variance_mean": round(sum(variances[half:]) / max(1, len(variances[half:])), 3), "lower_variance_mean": round(sum(variances[half:]) / max(1, len(variances[half:])), 3),
"global_mean": round(global_mean, 3),
"global_variance": round(global_variance, 3),
"previous_global_variance": round(previous_global_variance, 3),
} }
def exact_repeat_is_hard(run_frames: int, fps: int, max_seconds: float) -> bool:
threshold = max(1, round(max(0.0, max_seconds) * max(1, fps)))
return run_frames >= threshold
def write_pgm(path: pathlib.Path, frame: bytes, width: int, height: int) -> None: def write_pgm(path: pathlib.Path, frame: bytes, width: int, height: int) -> None:
path.write_bytes(f"P5\n{width} {height}\n255\n".encode() + frame) path.write_bytes(f"P5\n{width} {height}\n255\n".encode() + frame)
@ -537,11 +576,20 @@ def run_capture(args: argparse.Namespace) -> int:
max_upper_delta = 0.0 max_upper_delta = 0.0
max_lower_delta = 0.0 max_lower_delta = 0.0
max_lower_jump_seen = 0.0 max_lower_jump_seen = 0.0
exact_repeat_run = 0
max_exact_repeat_run = 0
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 previous, frame_index, suspicious_count, hard_corruption_count, artifacts_written, reference_artifacts_written nonlocal previous, frame_index, suspicious_count, hard_corruption_count, artifacts_written, reference_artifacts_written
nonlocal changed_frames, static_frames, max_upper_delta, max_lower_delta, max_lower_jump_seen, worst nonlocal changed_frames, static_frames, max_upper_delta, max_lower_delta, max_lower_jump_seen, worst
nonlocal exact_repeat_run, max_exact_repeat_run
frame_index += 1 frame_index += 1
exact_repeat_run = exact_repeat_run + 1 if previous is not None and frame == previous else 0
max_exact_repeat_run = max(max_exact_repeat_run, exact_repeat_run)
result = analyze_frame(frame, previous, args) result = analyze_frame(frame, previous, args)
result["exact_repeat_run"] = exact_repeat_run
if exact_repeat_is_hard(exact_repeat_run, args.fps, args.max_exact_repeat_seconds):
result["suspicious"] = True
result["reasons"].append("exact_freeze")
previous = frame previous = frame
result.update({"frame": frame_index, "elapsed_s": round(elapsed_s, 3)}) result.update({"frame": frame_index, "elapsed_s": round(elapsed_s, 3)})
max_upper_delta = max(max_upper_delta, float(result["upper_delta"])) max_upper_delta = max(max_upper_delta, float(result["upper_delta"]))
@ -778,6 +826,8 @@ def run_capture(args: argparse.Namespace) -> int:
"changed_frames": changed_frames, "changed_frames": changed_frames,
"static_frames": static_frames, "static_frames": static_frames,
"static_pct": round((static_frames / max(1, frame_index - 1) * 100.0) if frame_index > 1 else 0.0, 3), "static_pct": round((static_frames / max(1, frame_index - 1) * 100.0) if frame_index > 1 else 0.0, 3),
"max_exact_repeat_frames": max_exact_repeat_run,
"max_exact_repeat_seconds": round(max_exact_repeat_run / max(1, args.fps), 3),
"max_upper_delta": round(max_upper_delta, 3), "max_upper_delta": round(max_upper_delta, 3),
"max_lower_delta": round(max_lower_delta, 3), "max_lower_delta": round(max_lower_delta, 3),
"max_lower_jump_seen": round(max_lower_jump_seen, 3), "max_lower_jump_seen": round(max_lower_jump_seen, 3),
@ -811,6 +861,7 @@ def format_summary(summary: dict[str, Any]) -> str:
f"hard corruption: {summary.get('hard_corruption_frames', 0)}", f"hard corruption: {summary.get('hard_corruption_frames', 0)}",
f"review only: {summary.get('review_only_frames', 0)}", f"review only: {summary.get('review_only_frames', 0)}",
f"static: {summary.get('static_frames', 0)} ({summary.get('static_pct', 0.0)}%)", f"static: {summary.get('static_frames', 0)} ({summary.get('static_pct', 0.0)}%)",
f"max exact repeat: {summary.get('max_exact_repeat_frames', 0)} frames ({summary.get('max_exact_repeat_seconds', 0.0)} s)",
f"max deltas: upper={summary.get('max_upper_delta', 0.0)} lower={summary.get('max_lower_delta', 0.0)}", f"max deltas: upper={summary.get('max_upper_delta', 0.0)} lower={summary.get('max_lower_delta', 0.0)}",
f"reasons: {summary['reason_counts']}", f"reasons: {summary['reason_counts']}",
f"reference artifacts: {summary.get('reference_artifacts', 0)}", f"reference artifacts: {summary.get('reference_artifacts', 0)}",
@ -865,6 +916,7 @@ def run_self_test(args: argparse.Namespace) -> int:
rich = synthetic_rich_frame(args.width, args.height) rich = synthetic_rich_frame(args.width, args.height)
frames.append(rich) frames.append(rich)
frames.append(synthetic_shift_frame(rich, args.width, args.height, 24)) frames.append(synthetic_shift_frame(rich, args.width, args.height, 24))
frames.append(bytes([96]) * (args.width * args.height))
previous = None previous = None
suspicious = 0 suspicious = 0
records = [] records = []
@ -889,6 +941,10 @@ def run_self_test(args: argparse.Namespace) -> int:
"artifact_dir": str(artifact_dir), "artifact_dir": str(artifact_dir),
} }
(artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") (artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
if "global_flat_flash" not in records[-1]["reasons"]:
raise AssertionError("full-frame flat collapse was not classified as hard corruption")
if not exact_repeat_is_hard(40, 20, 2.0) or exact_repeat_is_hard(39, 20, 2.0):
raise AssertionError("exact-repeat freeze threshold is not duration bounded")
print(json.dumps(summary, indent=2, sort_keys=True)) print(json.dumps(summary, indent=2, sort_keys=True))
return 0 if suspicious >= 1 else 1 return 0 if suspicious >= 1 else 1

View File

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

View File

@ -27,6 +27,8 @@ fn rct_uvc_artifact_probe_documents_late_path_lower_half_detection() {
"lower_boundary_jump", "lower_boundary_jump",
"lower_flat_flash", "lower_flat_flash",
"lower_slab", "lower_slab",
"global_flat_flash",
"exact_freeze",
"temporal_delta_spike", "temporal_delta_spike",
"horizontal_shift", "horizontal_shift",
"lower_blur_flash", "lower_blur_flash",
@ -37,6 +39,7 @@ fn rct_uvc_artifact_probe_documents_late_path_lower_half_detection() {
"--shift-threshold", "--shift-threshold",
"--blur-delta-threshold", "--blur-delta-threshold",
"--change-threshold", "--change-threshold",
"--max-exact-repeat-seconds",
"--reference-every", "--reference-every",
"static_pct", "static_pct",
"reference_", "reference_",
@ -102,15 +105,15 @@ fn rct_uvc_artifact_probe_self_test_flags_synthetic_lower_half_slab() {
summary["schema"], summary["schema"],
"lesavka.rct-uvc-artifact-probe.self-test.v1" "lesavka.rct-uvc-artifact-probe.self-test.v1"
); );
assert_eq!(summary["frames"], 7); assert_eq!(summary["frames"], 8);
assert!( assert!(
summary["suspicious_frames"].as_u64().unwrap_or_default() >= 1, summary["suspicious_frames"].as_u64().unwrap_or_default() >= 1,
"self-test should detect the synthetic lower-half slab: {summary}" "self-test should detect the synthetic lower-half slab: {summary}"
); );
assert_eq!( assert_eq!(
summary["hard_corruption_frames"].as_u64(), summary["hard_corruption_frames"].as_u64(),
Some(1), Some(2),
"only the synthetic slab should be a hard corruption failure: {summary}" "the synthetic slab and full-frame flat collapse should be hard failures: {summary}"
); );
assert!( assert!(
dir.path().join("reference_000001.pgm").exists(), dir.path().join("reference_000001.pgm").exists(),
@ -145,6 +148,14 @@ fn rct_uvc_artifact_probe_self_test_flags_synthetic_lower_half_slab() {
}), }),
"self-test should catch transient tear spikes: {summary}" "self-test should catch transient tear spikes: {summary}"
); );
assert!(
records.iter().any(|record| {
record["reasons"]
.as_array()
.is_some_and(|reasons| reasons.iter().any(|reason| reason == "global_flat_flash"))
}),
"self-test should catch a full-frame grey/black collapse: {summary}"
);
assert!( assert!(
dir.path().join("selftest_suspicious_000005.pgm").exists(), dir.path().join("selftest_suspicious_000005.pgm").exists(),
"probe should save visual evidence for suspicious frames" "probe should save visual evidence for suspicious frames"