diagnostics: add synthetic rct uvc probe
This commit is contained in:
parent
46538c4f44
commit
260cb263f6
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.46"
|
||||
version = "0.22.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@ -1686,7 +1686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.46"
|
||||
version = "0.22.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@ -1698,7 +1698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.46"
|
||||
version = "0.22.47"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@ -472,6 +472,10 @@ path = "tests/manual/server/rct/server_rct_mode_matrix_manual_contract.rs"
|
||||
name = "rct_uvc_artifact_probe_manual_contract"
|
||||
path = "tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs"
|
||||
|
||||
[[test]]
|
||||
name = "synthetic_rct_uvc_probe_manual_contract"
|
||||
path = "tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs"
|
||||
|
||||
[[test]]
|
||||
name = "google_meet_observer_manual_contract"
|
||||
path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs"
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.46"
|
||||
version = "0.22.47"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.46"
|
||||
version = "0.22.47"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -1524,6 +1524,7 @@ run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC_DIR/server' && cargo clean &&
|
||||
echo "==> 5. Install binaries"
|
||||
install_verified_executable "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server "lesavka-server"
|
||||
install_verified_executable "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc "lesavka-uvc"
|
||||
install_verified_executable "$SRC_DIR/target/release/lesavka-synthetic-uplink" /usr/local/bin/lesavka-synthetic-uplink "lesavka-synthetic-uplink"
|
||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh"
|
||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh"
|
||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder"
|
||||
|
||||
710
scripts/manual/run_synthetic_rct_uvc_probe.py
Executable file
710
scripts/manual/run_synthetic_rct_uvc_probe.py
Executable file
@ -0,0 +1,710 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run synthetic Lesavka uplink media and compare what the RCT receives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_DEVICE_LABEL = "Lesavka Composite"
|
||||
DEFAULT_MODES = "1280x720@20,1280x720@30,1920x1080@20,1920x1080@30"
|
||||
MARKER_BITS = 32
|
||||
MARKER_COLUMNS = 16
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Manual synthetic end-to-end probe: Theia sends sequence-coded media "
|
||||
"through StreamWebcamMedia while Tethys captures the received UVC/X11 "
|
||||
"frames and compares them to the generated source."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--inject-host", default="", help="Theia SSH host, e.g. titan-jh")
|
||||
parser.add_argument("--rct-host", default="", help="RCT SSH host, e.g. tethys")
|
||||
parser.add_argument("--server", default="http://127.0.0.1:50051")
|
||||
parser.add_argument("--inject-binary", default="/usr/local/bin/lesavka-synthetic-uplink")
|
||||
parser.add_argument("--mode", default="1280x720@30", help=f"one mode; baseline set is {DEFAULT_MODES}")
|
||||
parser.add_argument("--width", type=int, default=0, help="override capture width")
|
||||
parser.add_argument("--height", type=int, default=0, help="override capture height")
|
||||
parser.add_argument("--fps", type=int, default=0, help="override capture fps")
|
||||
parser.add_argument("--duration", type=float, default=300.0)
|
||||
parser.add_argument("--source", choices=["device", "x11"], default="device")
|
||||
parser.add_argument("--device", default="auto")
|
||||
parser.add_argument("--device-label", default=DEFAULT_DEVICE_LABEL)
|
||||
parser.add_argument("--display", default=":0")
|
||||
parser.add_argument("--crop", default="", help="x,y,width,height for --source x11")
|
||||
parser.add_argument("--artifact-dir", default="")
|
||||
parser.add_argument("--remote-rct-dir", default="")
|
||||
parser.add_argument("--remote-inject-dir", default="")
|
||||
parser.add_argument("--x-step", type=int, default=8)
|
||||
parser.add_argument("--y-step", type=int, default=4)
|
||||
parser.add_argument("--bands", type=int, default=24)
|
||||
parser.add_argument("--mae-threshold", type=float, default=18.0)
|
||||
parser.add_argument("--lower-mae-threshold", type=float, default=28.0)
|
||||
parser.add_argument("--lower-skew-ratio", type=float, default=1.8)
|
||||
parser.add_argument("--slab-var", type=float, default=20.0)
|
||||
parser.add_argument("--shift-threshold", type=float, default=16.0)
|
||||
parser.add_argument("--shift-improvement", type=float, default=1.25)
|
||||
parser.add_argument("--max-suspicious-artifacts", type=int, default=80)
|
||||
parser.add_argument("--max-reference-artifacts", type=int, default=12)
|
||||
parser.add_argument("--reference-every", type=int, default=900)
|
||||
parser.add_argument("--progress-every", type=int, default=150)
|
||||
parser.add_argument("--capture-only", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def timestamp() -> str:
|
||||
return time.strftime("%Y%m%d-%H%M%S", time.gmtime())
|
||||
|
||||
|
||||
def parse_mode(value: str) -> tuple[int, int, int]:
|
||||
try:
|
||||
size, fps = value.lower().split("@", 1)
|
||||
width, height = size.split("x", 1)
|
||||
return int(width), int(height), int(fps)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"--mode must look like WIDTHxHEIGHT@FPS, got {value!r}") from exc
|
||||
|
||||
|
||||
def mode_dimensions(args: argparse.Namespace) -> tuple[int, int, int]:
|
||||
width, height, fps = parse_mode(args.mode)
|
||||
if args.width:
|
||||
width = args.width
|
||||
if args.height:
|
||||
height = args.height
|
||||
if args.fps:
|
||||
fps = args.fps
|
||||
if width <= 0 or height <= 0 or fps <= 0:
|
||||
raise SystemExit("width, height, and fps must be positive")
|
||||
return width, height, fps
|
||||
|
||||
|
||||
def default_artifact_dir(mode: str) -> pathlib.Path:
|
||||
safe_mode = mode.replace("@", "-").replace("x", "x")
|
||||
return pathlib.Path("artifacts/synthetic-rct") / f"{safe_mode}-{timestamp()}"
|
||||
|
||||
|
||||
def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
if not args.inject_host or not args.rct_host:
|
||||
raise SystemExit("--inject-host and --rct-host are required unless --capture-only or --self-test is used")
|
||||
if not shutil.which("ssh") or not shutil.which("scp"):
|
||||
raise SystemExit("ssh and scp are required for the remote synthetic probe")
|
||||
width, height, fps = mode_dimensions(args)
|
||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}"
|
||||
remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{timestamp()}"
|
||||
remote_script = f"/tmp/lesavka-synthetic-rct-probe-{os.getpid()}.py"
|
||||
script_text = pathlib.Path(__file__).read_text()
|
||||
subprocess.run(
|
||||
["ssh", args.rct_host, f"cat > {shlex.quote(remote_script)} && chmod +x {shlex.quote(remote_script)}"],
|
||||
input=script_text,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
capture_cmd = [
|
||||
"python3",
|
||||
remote_script,
|
||||
"--capture-only",
|
||||
"--mode",
|
||||
args.mode,
|
||||
"--width",
|
||||
str(width),
|
||||
"--height",
|
||||
str(height),
|
||||
"--fps",
|
||||
str(fps),
|
||||
"--duration",
|
||||
str(args.duration),
|
||||
"--source",
|
||||
args.source,
|
||||
"--device",
|
||||
args.device,
|
||||
"--device-label",
|
||||
args.device_label,
|
||||
"--display",
|
||||
args.display,
|
||||
"--crop",
|
||||
args.crop,
|
||||
"--artifact-dir",
|
||||
remote_rct_dir,
|
||||
"--x-step",
|
||||
str(args.x_step),
|
||||
"--y-step",
|
||||
str(args.y_step),
|
||||
"--bands",
|
||||
str(args.bands),
|
||||
"--mae-threshold",
|
||||
str(args.mae_threshold),
|
||||
"--lower-mae-threshold",
|
||||
str(args.lower_mae_threshold),
|
||||
"--lower-skew-ratio",
|
||||
str(args.lower_skew_ratio),
|
||||
"--slab-var",
|
||||
str(args.slab_var),
|
||||
"--shift-threshold",
|
||||
str(args.shift_threshold),
|
||||
"--shift-improvement",
|
||||
str(args.shift_improvement),
|
||||
"--max-suspicious-artifacts",
|
||||
str(args.max_suspicious_artifacts),
|
||||
"--max-reference-artifacts",
|
||||
str(args.max_reference_artifacts),
|
||||
"--reference-every",
|
||||
str(args.reference_every),
|
||||
"--progress-every",
|
||||
str(args.progress_every),
|
||||
]
|
||||
inject_cmd = [
|
||||
args.inject_binary,
|
||||
"--server",
|
||||
args.server,
|
||||
"--mode",
|
||||
args.mode,
|
||||
"--duration",
|
||||
str(args.duration + 2.0),
|
||||
"--artifact-dir",
|
||||
remote_inject_dir,
|
||||
"--print-every",
|
||||
str(args.progress_every),
|
||||
]
|
||||
(artifact_dir / "orchestrator-command.txt").write_text(" ".join(sys.argv) + "\n")
|
||||
(artifact_dir / "mode.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "lesavka.synthetic-rct-probe.run.v1",
|
||||
"mode": args.mode,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fps": fps,
|
||||
"source": args.source,
|
||||
"duration_s": args.duration,
|
||||
"inject_host": args.inject_host,
|
||||
"rct_host": args.rct_host,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
print(f"starting RCT capture on {args.rct_host}: {remote_rct_dir}", file=sys.stderr)
|
||||
capture = subprocess.Popen(["ssh", args.rct_host, " ".join(shlex.quote(part) for part in capture_cmd)])
|
||||
time.sleep(1.0)
|
||||
print(f"starting synthetic uplink on {args.inject_host}: {remote_inject_dir}", file=sys.stderr)
|
||||
inject = subprocess.Popen(["ssh", args.inject_host, " ".join(shlex.quote(part) for part in inject_cmd)])
|
||||
inject_rc = inject.wait()
|
||||
capture_rc = capture.wait()
|
||||
local_capture = artifact_dir / "capture"
|
||||
local_inject = artifact_dir / "inject"
|
||||
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
|
||||
subprocess.run(["scp", "-r", f"{args.inject_host}:{remote_inject_dir}", str(local_inject)], check=False)
|
||||
summary = {
|
||||
"schema": "lesavka.synthetic-rct-probe.orchestrator.v1",
|
||||
"mode": args.mode,
|
||||
"capture_rc": capture_rc,
|
||||
"inject_rc": inject_rc,
|
||||
"artifact_dir": str(artifact_dir),
|
||||
"capture_artifacts": str(local_capture),
|
||||
"inject_artifacts": str(local_inject),
|
||||
}
|
||||
(artifact_dir / "run-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
print(f"artifact_dir: {artifact_dir}")
|
||||
return 0 if capture_rc == 0 and inject_rc == 0 else 1
|
||||
|
||||
|
||||
def detect_video_device(label: str) -> str:
|
||||
explicit = os.environ.get("LESAVKA_RCT_UVC_DEVICE")
|
||||
if explicit:
|
||||
return explicit
|
||||
try:
|
||||
listing = subprocess.check_output(["v4l2-ctl", "--list-devices"], text=True)
|
||||
except Exception:
|
||||
return "/dev/video2"
|
||||
current_matches = False
|
||||
for line in listing.splitlines():
|
||||
if not line.startswith(("\t", " ")):
|
||||
current_matches = label.lower() in line.lower()
|
||||
continue
|
||||
value = line.strip()
|
||||
if current_matches and value.startswith("/dev/video"):
|
||||
return value
|
||||
return "/dev/video2"
|
||||
|
||||
|
||||
def parse_crop(args: argparse.Namespace, width: int, height: int) -> tuple[int, int, int, int]:
|
||||
if not args.crop:
|
||||
return 0, 0, width, height
|
||||
parts = [part.strip() for part in args.crop.split(",")]
|
||||
if len(parts) != 4:
|
||||
raise SystemExit("--crop must be x,y,width,height")
|
||||
x, y, crop_width, crop_height = [int(part) for part in parts]
|
||||
if crop_width <= 0 or crop_height <= 0:
|
||||
raise SystemExit("--crop width and height must be positive")
|
||||
return x, y, crop_width, crop_height
|
||||
|
||||
|
||||
def ffmpeg_cmd(args: argparse.Namespace, width: int, height: int) -> tuple[list[str], int, int, str]:
|
||||
if args.source == "x11":
|
||||
x, y, capture_width, capture_height = parse_crop(args, width, height)
|
||||
display = f"{args.display}+{x},{y}"
|
||||
return (
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-f",
|
||||
"x11grab",
|
||||
"-video_size",
|
||||
f"{capture_width}x{capture_height}",
|
||||
"-framerate",
|
||||
str(args.fps or parse_mode(args.mode)[2]),
|
||||
"-i",
|
||||
display,
|
||||
"-an",
|
||||
"-pix_fmt",
|
||||
"gray",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
capture_width,
|
||||
capture_height,
|
||||
display,
|
||||
)
|
||||
device = detect_video_device(args.device_label) if args.device == "auto" else args.device
|
||||
return (
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-f",
|
||||
"v4l2",
|
||||
"-input_format",
|
||||
"mjpeg",
|
||||
"-video_size",
|
||||
f"{width}x{height}",
|
||||
"-framerate",
|
||||
str(args.fps or parse_mode(args.mode)[2]),
|
||||
"-i",
|
||||
device,
|
||||
"-an",
|
||||
"-pix_fmt",
|
||||
"gray",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
width,
|
||||
height,
|
||||
device,
|
||||
)
|
||||
|
||||
|
||||
def marker_cell(width: int, height: int) -> int:
|
||||
return max(6, min(16, min(width, height) // 80))
|
||||
|
||||
|
||||
def fill_rect(frame: bytearray, width: int, height: int, x0: int, y0: int, w: int, h: int, value: int) -> None:
|
||||
for y in range(max(0, y0), min(height, y0 + h)):
|
||||
row = y * width
|
||||
for x in range(max(0, x0), min(width, x0 + w)):
|
||||
frame[row + x] = value
|
||||
|
||||
|
||||
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
|
||||
data = bytearray(width * height)
|
||||
moving_period = max(width // 3, 64)
|
||||
moving_width = max(width // 18, 12)
|
||||
moving_offset = (sequence * 17) % moving_period
|
||||
center_x = width // 2
|
||||
center_y = height // 2
|
||||
for y in range(height):
|
||||
row = y * width
|
||||
for x in range(width):
|
||||
value = (x * 3 + y * 5 + sequence * 11) & 0xFF
|
||||
if (x + moving_offset) % moving_period < moving_width:
|
||||
value = min(255, value + 70)
|
||||
if abs(x - center_x) < width // 9 and abs(y - center_y) < height // 12:
|
||||
value = 255 - value // 2
|
||||
if y >= height // 2 and (((x // 32) + (y // 24) + sequence) & 1) == 0:
|
||||
value //= 3
|
||||
data[row + x] = value
|
||||
draw_marker(data, width, height, sequence)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def draw_marker(frame: bytearray, width: int, height: int, sequence: 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
|
||||
x0 = 2 * cell
|
||||
y0 = 2 * cell
|
||||
fill_rect(frame, width, height, cell, cell, (MARKER_COLUMNS + 2) * cell, (rows + 2) * cell, 32)
|
||||
fill_rect(frame, width, height, x0 - cell, y0 - cell, cell, cell, 255)
|
||||
fill_rect(frame, width, height, x0 + MARKER_COLUMNS * cell, y0 - cell, cell, cell, 0)
|
||||
for bit in range(MARKER_BITS):
|
||||
col = bit % MARKER_COLUMNS
|
||||
row = bit // MARKER_COLUMNS
|
||||
value = 255 if ((sequence >> bit) & 1) else 0
|
||||
fill_rect(frame, width, height, x0 + col * cell, y0 + row * cell, cell, cell, value)
|
||||
|
||||
|
||||
def cell_mean(frame: bytes, width: int, x0: int, y0: int, cell: int) -> float:
|
||||
total = 0
|
||||
count = 0
|
||||
inset = max(1, cell // 4)
|
||||
for y in range(y0 + inset, y0 + cell - inset):
|
||||
row = y * width
|
||||
for x in range(x0 + inset, x0 + cell - inset):
|
||||
total += frame[row + x]
|
||||
count += 1
|
||||
return total / max(1, count)
|
||||
|
||||
|
||||
def decode_sequence(frame: bytes, width: int, height: int) -> tuple[int | None, int]:
|
||||
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_BITS
|
||||
x0 = 2 * cell
|
||||
y0 = 2 * cell
|
||||
value = 0
|
||||
uncertain = 0
|
||||
for bit in range(MARKER_BITS):
|
||||
col = bit % MARKER_COLUMNS
|
||||
row = bit // MARKER_COLUMNS
|
||||
mean = cell_mean(frame, width, x0 + col * cell, y0 + row * cell, cell)
|
||||
if mean > 165:
|
||||
value |= 1 << bit
|
||||
elif mean >= 90:
|
||||
uncertain += 1
|
||||
if uncertain > 6:
|
||||
return None, 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:
|
||||
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])
|
||||
count += 1
|
||||
return total / max(1, count)
|
||||
|
||||
|
||||
def band_stats(frame: bytes, width: int, y0: int, y1: int, x_step: int, y_step: int) -> tuple[float, float]:
|
||||
total = 0
|
||||
total2 = 0
|
||||
count = 0
|
||||
for y in range(y0, y1, y_step):
|
||||
row = y * width
|
||||
for x in range(0, width, x_step):
|
||||
value = frame[row + x]
|
||||
total += value
|
||||
total2 += value * value
|
||||
count += 1
|
||||
mean = total / max(1, count)
|
||||
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:
|
||||
x0 = max(0, -shift)
|
||||
x1 = min(width, width - shift)
|
||||
if x0 >= x1:
|
||||
return 0.0
|
||||
y0 = height // 4
|
||||
total = 0
|
||||
count = 0
|
||||
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])
|
||||
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)
|
||||
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)
|
||||
if candidate < best:
|
||||
best = candidate
|
||||
best_shift = shift
|
||||
improvement = zero / max(best, 0.001) if best_shift else 1.0
|
||||
return best_shift, zero, best, improvement
|
||||
|
||||
|
||||
def max_run(flags: list[bool]) -> int:
|
||||
best = 0
|
||||
current = 0
|
||||
for flag in flags:
|
||||
current = current + 1 if flag else 0
|
||||
best = max(best, current)
|
||||
return best
|
||||
|
||||
|
||||
def analyze_frame(
|
||||
frame: bytes,
|
||||
width: int,
|
||||
height: int,
|
||||
args: argparse.Namespace,
|
||||
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)
|
||||
|
||||
band_count = max(8, args.bands)
|
||||
band_h = max(1, height // band_count)
|
||||
means: list[float] = []
|
||||
variances: list[float] = []
|
||||
for band in range(band_count):
|
||||
y0 = band * band_h
|
||||
y1 = height if band == band_count - 1 else min(height, y0 + band_h)
|
||||
mean, variance = band_stats(frame, width, y0, y1, args.x_step, args.y_step)
|
||||
means.append(mean)
|
||||
variances.append(variance)
|
||||
lower = band_count // 2
|
||||
lower_flags = [var < args.slab_var for var in variances[lower:]]
|
||||
low_var_run = max_run(lower_flags) / max(1, len(lower_flags))
|
||||
mean_jumps = [abs(means[idx] - means[idx - 1]) for idx in range(1, band_count)]
|
||||
max_lower_jump = max(mean_jumps[lower:] or [0.0])
|
||||
|
||||
reasons: list[str] = []
|
||||
if sequence is None:
|
||||
reasons.append("marker_decode_failed")
|
||||
elif previous_seq is not None:
|
||||
if sequence == previous_seq:
|
||||
reasons.append("frame_repeat")
|
||||
elif sequence > previous_seq + 1:
|
||||
reasons.append("frame_gap")
|
||||
elif sequence < previous_seq:
|
||||
reasons.append("frame_backwards")
|
||||
if expected 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):
|
||||
reasons.append("high_mae")
|
||||
if low_var_run >= 0.25 and lower_mae > args.lower_mae_threshold:
|
||||
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")
|
||||
return {
|
||||
"suspicious": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"decoded_sequence": sequence,
|
||||
"marker_uncertain_bits": uncertain_bits,
|
||||
"upper_mae": round(upper_mae, 3),
|
||||
"lower_mae": round(lower_mae, 3),
|
||||
"total_mae": round(total_mae, 3),
|
||||
"lower_low_variance_run_pct": round(low_var_run, 3),
|
||||
"max_lower_jump": round(max_lower_jump, 3),
|
||||
"shift_pixels": shift_pixels,
|
||||
"shift_zero_delta": round(shift_zero_delta, 3),
|
||||
"shift_best_delta": round(shift_best_delta, 3),
|
||||
"shift_improvement": round(shift_improvement, 3),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def run_capture(args: argparse.Namespace) -> int:
|
||||
width, height, fps = mode_dimensions(args)
|
||||
command, capture_width, capture_height, device = ffmpeg_cmd(args, width, height)
|
||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-capture-{timestamp()}"
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
frame_size = capture_width * capture_height
|
||||
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n")
|
||||
stderr_path = artifact_dir / "ffmpeg.stderr"
|
||||
metrics_path = artifact_dir / "frame-metrics.jsonl"
|
||||
started = time.monotonic()
|
||||
frame_index = 0
|
||||
suspicious_count = 0
|
||||
reference_artifacts = 0
|
||||
suspicious_artifacts = 0
|
||||
previous_seq: int | None = None
|
||||
decoded_frames = 0
|
||||
reason_counts: collections.Counter[str] = collections.Counter()
|
||||
max_total_mae = max_upper_mae = max_lower_mae = 0.0
|
||||
worst: list[dict[str, Any]] = []
|
||||
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err)
|
||||
assert proc.stdout is not None
|
||||
try:
|
||||
while time.monotonic() - started < args.duration:
|
||||
frame = proc.stdout.read(frame_size)
|
||||
if len(frame) != frame_size:
|
||||
break
|
||||
frame_index += 1
|
||||
result = analyze_frame(frame, capture_width, capture_height, args, previous_seq)
|
||||
decoded_seq = result["decoded_sequence"]
|
||||
if decoded_seq is not None:
|
||||
decoded_frames += 1
|
||||
previous_seq = int(decoded_seq)
|
||||
result.update({"frame": frame_index, "elapsed_s": round(time.monotonic() - started, 3)})
|
||||
max_total_mae = max(max_total_mae, float(result["total_mae"]))
|
||||
max_upper_mae = max(max_upper_mae, float(result["upper_mae"]))
|
||||
max_lower_mae = max(max_lower_mae, float(result["lower_mae"]))
|
||||
if result["suspicious"]:
|
||||
suspicious_count += 1
|
||||
reason_counts.update(result["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:
|
||||
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:
|
||||
write_pgm(
|
||||
artifact_dir / f"expected_{frame_index:06d}_{seq_label}.pgm",
|
||||
synthetic_gray(capture_width, capture_height, int(decoded_seq)),
|
||||
capture_width,
|
||||
capture_height,
|
||||
)
|
||||
suspicious_artifacts += 1
|
||||
should_reference = frame_index == 1 or (args.reference_every > 0 and frame_index % args.reference_every == 0)
|
||||
if should_reference and reference_artifacts < args.max_reference_artifacts:
|
||||
write_pgm(artifact_dir / f"reference_{frame_index:06d}.pgm", frame, capture_width, capture_height)
|
||||
reference_artifacts += 1
|
||||
metrics.write(json.dumps(result, sort_keys=True) + "\n")
|
||||
if frame_index % args.progress_every == 0:
|
||||
print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr)
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
elapsed = max(0.001, time.monotonic() - started)
|
||||
summary = {
|
||||
"schema": "lesavka.synthetic-rct-capture.v1",
|
||||
"source": args.source,
|
||||
"device": device,
|
||||
"mode": args.mode,
|
||||
"width": capture_width,
|
||||
"height": capture_height,
|
||||
"fps_requested": fps,
|
||||
"duration_requested_s": args.duration,
|
||||
"duration_observed_s": round(elapsed, 3),
|
||||
"frames": frame_index,
|
||||
"fps_observed": round(frame_index / elapsed, 3),
|
||||
"decoded_frames": decoded_frames,
|
||||
"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,
|
||||
"reason_counts": dict(reason_counts),
|
||||
"max_total_mae": round(max_total_mae, 3),
|
||||
"max_upper_mae": round(max_upper_mae, 3),
|
||||
"max_lower_mae": round(max_lower_mae, 3),
|
||||
"worst_frames": worst,
|
||||
"reference_artifacts": reference_artifacts,
|
||||
"suspicious_artifacts": suspicious_artifacts,
|
||||
"artifact_dir": str(artifact_dir),
|
||||
"ffmpeg_stderr": str(stderr_path),
|
||||
}
|
||||
(artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||
(artifact_dir / "summary.txt").write_text(format_summary(summary))
|
||||
print(format_summary(summary), end="")
|
||||
print(f"artifact_dir: {artifact_dir}")
|
||||
return 0 if frame_index > 0 else 2
|
||||
|
||||
|
||||
def format_summary(summary: dict[str, Any]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"Lesavka synthetic RCT UVC comparison probe",
|
||||
f"source: {summary['source']}",
|
||||
f"device: {summary['device']}",
|
||||
f"mode: {summary['mode']} capture={summary['width']}x{summary['height']}@{summary['fps_requested']}",
|
||||
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"reasons: {summary['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']}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def run_self_test(args: argparse.Namespace) -> int:
|
||||
width = 320
|
||||
height = 180
|
||||
frames = [synthetic_gray(width, height, idx) for idx in range(6)]
|
||||
corrupt = bytearray(synthetic_gray(width, height, 6))
|
||||
fill_rect(corrupt, width, height, 0, height // 2, width, height // 4, 128)
|
||||
frames.append(bytes(corrupt))
|
||||
shifted = bytearray(width * height)
|
||||
expected = synthetic_gray(width, height, 7)
|
||||
for y in range(height):
|
||||
row = y * width
|
||||
for x in range(width):
|
||||
src = min(width - 1, x + 24)
|
||||
shifted[row + x] = expected[row + src]
|
||||
frames.append(bytes(shifted))
|
||||
previous_seq: int | None = None
|
||||
records: list[dict[str, Any]] = []
|
||||
suspicious = 0
|
||||
for idx, frame in enumerate(frames):
|
||||
result = analyze_frame(frame, width, height, args, previous_seq)
|
||||
if result["decoded_sequence"] is not None:
|
||||
previous_seq = int(result["decoded_sequence"])
|
||||
result["frame"] = idx
|
||||
records.append(result)
|
||||
suspicious += int(bool(result["suspicious"]))
|
||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-self-test-{timestamp()}"
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
write_pgm(artifact_dir / "reference_000001.pgm", frames[0], width, height)
|
||||
summary = {
|
||||
"schema": "lesavka.synthetic-rct-probe.self-test.v1",
|
||||
"frames": len(frames),
|
||||
"suspicious_frames": suspicious,
|
||||
"records": records,
|
||||
"artifact_dir": str(artifact_dir),
|
||||
}
|
||||
(artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
return 0 if suspicious >= 2 else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.self_test:
|
||||
return run_self_test(args)
|
||||
if args.capture_only:
|
||||
return run_capture(args)
|
||||
return run_remote_orchestrated(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -8,9 +8,15 @@ path = "src/bin/lesavka-uvc.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "lesavka-synthetic-uplink"
|
||||
path = "src/bin/lesavka-synthetic-uplink.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.46"
|
||||
version = "0.22.47"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
501
server/src/bin/lesavka-synthetic-uplink.rs
Executable file
501
server/src/bin/lesavka-synthetic-uplink.rs
Executable file
@ -0,0 +1,501 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer_app as gst_app;
|
||||
use lesavka_common::lesavka::{
|
||||
AudioEncoding, AudioPacket, UpstreamMediaBundle, VideoPacket, relay_client::RelayClient,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::Request;
|
||||
|
||||
const DEFAULT_SERVER: &str = "http://127.0.0.1:50051";
|
||||
const DEFAULT_SAMPLE_RATE: u32 = 48_000;
|
||||
const DEFAULT_CHANNELS: u32 = 2;
|
||||
const DEFAULT_JPEG_QUALITY: i32 = 90;
|
||||
const MARKER_BITS: usize = 32;
|
||||
const MARKER_COLUMNS: usize = 16;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Args {
|
||||
server: String,
|
||||
width: usize,
|
||||
height: usize,
|
||||
fps: u32,
|
||||
duration: Duration,
|
||||
jpeg_quality: i32,
|
||||
session_id: u64,
|
||||
artifact_dir: Option<PathBuf>,
|
||||
print_every: u64,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Result<Self> {
|
||||
let mut args = Self {
|
||||
server: DEFAULT_SERVER.to_string(),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
fps: 30,
|
||||
duration: Duration::from_secs(300),
|
||||
jpeg_quality: DEFAULT_JPEG_QUALITY,
|
||||
session_id: unix_millis(),
|
||||
artifact_dir: None,
|
||||
print_every: 150,
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(flag) = it.next() {
|
||||
match flag.as_str() {
|
||||
"--server" => args.server = next_value(&mut it, &flag)?,
|
||||
"--width" => args.width = parse_next(&mut it, &flag)?,
|
||||
"--height" => args.height = parse_next(&mut it, &flag)?,
|
||||
"--fps" => args.fps = parse_next(&mut it, &flag)?,
|
||||
"--duration" => {
|
||||
let seconds: f64 = parse_next(&mut it, &flag)?;
|
||||
args.duration = Duration::from_secs_f64(seconds.max(0.0));
|
||||
}
|
||||
"--jpeg-quality" => args.jpeg_quality = parse_next(&mut it, &flag)?,
|
||||
"--session-id" => args.session_id = parse_next(&mut it, &flag)?,
|
||||
"--artifact-dir" => {
|
||||
args.artifact_dir = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
||||
}
|
||||
"--print-every" => args.print_every = parse_next(&mut it, &flag)?,
|
||||
"--mode" => {
|
||||
let value = next_value(&mut it, &flag)?;
|
||||
let (width, height, fps) = parse_mode(&value)?;
|
||||
args.width = width;
|
||||
args.height = height;
|
||||
args.fps = fps;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => bail!("unknown argument {other:?}; pass --help for usage"),
|
||||
}
|
||||
}
|
||||
if args.width == 0 || args.height == 0 || args.fps == 0 {
|
||||
bail!("width, height, and fps must be positive");
|
||||
}
|
||||
args.jpeg_quality = args.jpeg_quality.clamp(1, 100);
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn frame_step_us(&self) -> u64 {
|
||||
(1_000_000_u64 / u64::from(self.fps)).max(1)
|
||||
}
|
||||
|
||||
fn total_frames(&self) -> u64 {
|
||||
let frames = self.duration.as_secs_f64() * f64::from(self.fps);
|
||||
frames.ceil().max(1.0) as u64
|
||||
}
|
||||
}
|
||||
|
||||
struct MjpegEncoder {
|
||||
src: gst_app::AppSrc,
|
||||
sink: gst_app::AppSink,
|
||||
pipeline: gst::Pipeline,
|
||||
width: usize,
|
||||
height: usize,
|
||||
frame_step_us: u64,
|
||||
}
|
||||
|
||||
impl MjpegEncoder {
|
||||
fn new(args: &Args) -> Result<Self> {
|
||||
gst::init().context("gst init")?;
|
||||
let width = args.width as i32;
|
||||
let height = args.height as i32;
|
||||
let fps = args.fps as i32;
|
||||
let raw_caps = gst::Caps::builder("video/x-raw")
|
||||
.field("format", "RGB")
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("framerate", gst::Fraction::new(fps, 1))
|
||||
.build();
|
||||
let jpeg_caps = gst::Caps::builder("image/jpeg")
|
||||
.field("parsed", true)
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("framerate", gst::Fraction::new(fps, 1))
|
||||
.build();
|
||||
let pipeline = gst::Pipeline::new();
|
||||
let src = gst::ElementFactory::make("appsrc")
|
||||
.name("lesavka_synthetic_uplink_src")
|
||||
.build()?
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
.expect("appsrc");
|
||||
src.set_is_live(false);
|
||||
src.set_format(gst::Format::Time);
|
||||
src.set_property("do-timestamp", false);
|
||||
src.set_caps(Some(&raw_caps));
|
||||
let convert = gst::ElementFactory::make("videoconvert").build()?;
|
||||
let encoder = gst::ElementFactory::make("jpegenc")
|
||||
.property("quality", args.jpeg_quality)
|
||||
.build()?;
|
||||
let capsfilter = gst::ElementFactory::make("capsfilter")
|
||||
.property("caps", &jpeg_caps)
|
||||
.build()?;
|
||||
let sink = gst::ElementFactory::make("appsink")
|
||||
.name("lesavka_synthetic_uplink_sink")
|
||||
.property("sync", false)
|
||||
.property("emit-signals", false)
|
||||
.property("max-buffers", 8u32)
|
||||
.build()?
|
||||
.downcast::<gst_app::AppSink>()
|
||||
.expect("appsink");
|
||||
pipeline.add_many([
|
||||
src.upcast_ref(),
|
||||
&convert,
|
||||
&encoder,
|
||||
&capsfilter,
|
||||
sink.upcast_ref(),
|
||||
])?;
|
||||
gst::Element::link_many([
|
||||
src.upcast_ref(),
|
||||
&convert,
|
||||
&encoder,
|
||||
&capsfilter,
|
||||
sink.upcast_ref(),
|
||||
])?;
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.context("starting synthetic MJPEG encoder")?;
|
||||
Ok(Self {
|
||||
src,
|
||||
sink,
|
||||
pipeline,
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
frame_step_us: args.frame_step_us(),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(&mut self, sequence: u64) -> Result<Vec<u8>> {
|
||||
let pts_us = sequence.saturating_mul(self.frame_step_us);
|
||||
let mut buffer =
|
||||
gst::Buffer::from_slice(synthetic_rgb_frame(self.width, self.height, sequence));
|
||||
if let Some(meta) = buffer.get_mut() {
|
||||
let pts = gst::ClockTime::from_useconds(pts_us);
|
||||
meta.set_pts(Some(pts));
|
||||
meta.set_dts(Some(pts));
|
||||
meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us)));
|
||||
}
|
||||
self.src
|
||||
.push_buffer(buffer)
|
||||
.context("encoding synthetic frame")?;
|
||||
let sample = self
|
||||
.sink
|
||||
.pull_sample()
|
||||
.context("pulling encoded synthetic frame")?;
|
||||
let buffer = sample
|
||||
.buffer()
|
||||
.context("encoded synthetic frame had no buffer")?;
|
||||
let map = buffer
|
||||
.map_readable()
|
||||
.context("mapping encoded synthetic frame")?;
|
||||
Ok(map.as_slice().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MjpegEncoder {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.src.end_of_stream();
|
||||
let _ = self.pipeline.set_state(gst::State::Null);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse()?;
|
||||
if let Some(dir) = &args.artifact_dir {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
std::fs::write(
|
||||
dir.join("command.txt"),
|
||||
std::env::args().collect::<Vec<_>>().join(" ") + "\n",
|
||||
)?;
|
||||
std::fs::write(dir.join("summary.json"), args_summary_json(&args) + "\n")?;
|
||||
}
|
||||
|
||||
let channel = tonic::transport::Channel::from_shared(args.server.clone())?
|
||||
.tcp_nodelay(true)
|
||||
.connect()
|
||||
.await
|
||||
.with_context(|| format!("connecting to {}", args.server))?;
|
||||
let mut client = RelayClient::new(channel);
|
||||
let (tx, rx) = mpsc::channel::<UpstreamMediaBundle>(8);
|
||||
let response_task = tokio::spawn(async move {
|
||||
let response = client
|
||||
.stream_webcam_media(Request::new(ReceiverStream::new(rx)))
|
||||
.await
|
||||
.context("opening StreamWebcamMedia")?;
|
||||
let mut inbound = response.into_inner();
|
||||
while inbound
|
||||
.message()
|
||||
.await
|
||||
.context("reading StreamWebcamMedia response")?
|
||||
.is_some()
|
||||
{}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
|
||||
let mut encoder = MjpegEncoder::new(&args)?;
|
||||
let frame_step = Duration::from_micros(args.frame_step_us());
|
||||
let started = tokio::time::Instant::now() + Duration::from_millis(250);
|
||||
let total_frames = args.total_frames();
|
||||
eprintln!(
|
||||
"lesavka synthetic uplink: mode={}x{}@{} frames={} server={} session={}",
|
||||
args.width, args.height, args.fps, total_frames, args.server, args.session_id
|
||||
);
|
||||
for sequence in 0..total_frames {
|
||||
tokio::time::sleep_until(started + duration_mul(frame_step, sequence)).await;
|
||||
let pts_us = sequence.saturating_mul(args.frame_step_us());
|
||||
let encoded = encoder.encode(sequence)?;
|
||||
let bundle = synthetic_bundle(&args, sequence, pts_us, encoded);
|
||||
tx.send(bundle).await.context("sending synthetic bundle")?;
|
||||
if args.print_every > 0 && sequence > 0 && sequence % args.print_every == 0 {
|
||||
eprintln!("sent synthetic frame {sequence}/{total_frames}");
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
response_task
|
||||
.await
|
||||
.context("joining StreamWebcamMedia task")??;
|
||||
eprintln!("lesavka synthetic uplink complete: frames={total_frames}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn synthetic_bundle(args: &Args, sequence: u64, pts_us: u64, data: Vec<u8>) -> UpstreamMediaBundle {
|
||||
let video = VideoPacket {
|
||||
id: 0,
|
||||
pts: pts_us,
|
||||
data,
|
||||
seq: sequence,
|
||||
effective_fps: args.fps,
|
||||
client_capture_pts_us: pts_us,
|
||||
client_send_pts_us: pts_us,
|
||||
..Default::default()
|
||||
};
|
||||
let audio = AudioPacket {
|
||||
id: 0,
|
||||
pts: pts_us,
|
||||
data: silence_pcm(args.frame_step_us()),
|
||||
seq: sequence,
|
||||
client_capture_pts_us: pts_us,
|
||||
client_send_pts_us: pts_us,
|
||||
encoding: AudioEncoding::PcmS16le as i32,
|
||||
sample_rate: DEFAULT_SAMPLE_RATE,
|
||||
channels: DEFAULT_CHANNELS,
|
||||
frame_duration_us: args.frame_step_us().min(u64::from(u32::MAX)) as u32,
|
||||
..Default::default()
|
||||
};
|
||||
UpstreamMediaBundle {
|
||||
session_id: args.session_id,
|
||||
seq: sequence,
|
||||
capture_start_us: pts_us,
|
||||
capture_end_us: pts_us,
|
||||
video: Some(video),
|
||||
audio: vec![audio],
|
||||
audio_sample_rate: DEFAULT_SAMPLE_RATE,
|
||||
audio_channels: DEFAULT_CHANNELS,
|
||||
video_width: args.width as u32,
|
||||
video_height: args.height as u32,
|
||||
video_fps: args.fps,
|
||||
audio_encoding: AudioEncoding::PcmS16le as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn silence_pcm(duration_us: u64) -> Vec<u8> {
|
||||
let samples = (u64::from(DEFAULT_SAMPLE_RATE).saturating_mul(duration_us) / 1_000_000).max(1);
|
||||
let bytes = samples
|
||||
.saturating_mul(u64::from(DEFAULT_CHANNELS))
|
||||
.saturating_mul(std::mem::size_of::<i16>() as u64)
|
||||
.min(usize::MAX as u64) as usize;
|
||||
vec![0; bytes]
|
||||
}
|
||||
|
||||
fn synthetic_rgb_frame(width: usize, height: usize, sequence: u64) -> Vec<u8> {
|
||||
let mut frame = vec![0u8; width.saturating_mul(height).saturating_mul(3)];
|
||||
let moving_period = (width / 3).max(64);
|
||||
let moving_width = (width / 18).max(12);
|
||||
let moving_offset = (sequence as usize).wrapping_mul(17) % moving_period;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let mut value = synthetic_luma(
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
sequence,
|
||||
moving_period,
|
||||
moving_width,
|
||||
moving_offset,
|
||||
);
|
||||
if y >= height / 2 && (((x / 32) + (y / 24) + sequence as usize) & 1) == 0 {
|
||||
value /= 3;
|
||||
}
|
||||
let offset = (y * width + x) * 3;
|
||||
frame[offset] = value;
|
||||
frame[offset + 1] = value;
|
||||
frame[offset + 2] = value;
|
||||
}
|
||||
}
|
||||
draw_sequence_marker(&mut frame, width, height, sequence);
|
||||
frame
|
||||
}
|
||||
|
||||
fn synthetic_luma(
|
||||
width: usize,
|
||||
height: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
sequence: u64,
|
||||
moving_period: usize,
|
||||
moving_width: usize,
|
||||
moving_offset: usize,
|
||||
) -> u8 {
|
||||
let mut value = ((x as u64 * 3 + y as u64 * 5 + sequence.saturating_mul(11)) & 0xff) as u8;
|
||||
let moving = (x + moving_offset) % moving_period;
|
||||
if moving < moving_width {
|
||||
value = value.saturating_add(70);
|
||||
}
|
||||
let center_x = width / 2;
|
||||
let center_y = height / 2;
|
||||
if x.abs_diff(center_x) < width / 9 && y.abs_diff(center_y) < height / 12 {
|
||||
value = 255u8.saturating_sub(value / 2);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn marker_cell(width: usize, height: usize) -> usize {
|
||||
(width.min(height) / 80).clamp(6, 16)
|
||||
}
|
||||
|
||||
fn draw_sequence_marker(frame: &mut [u8], width: usize, height: usize, sequence: u64) {
|
||||
let cell = marker_cell(width, height);
|
||||
let rows = MARKER_BITS.div_ceil(MARKER_COLUMNS);
|
||||
if width < (MARKER_COLUMNS + 4) * cell || height < (rows + 4) * cell {
|
||||
return;
|
||||
}
|
||||
let x0 = 2 * cell;
|
||||
let y0 = 2 * cell;
|
||||
fill_rect(
|
||||
frame,
|
||||
width,
|
||||
cell,
|
||||
cell,
|
||||
(MARKER_COLUMNS + 2) * cell,
|
||||
(rows + 2) * cell,
|
||||
32,
|
||||
);
|
||||
fill_rect(frame, width, x0 - cell, y0 - cell, cell, cell, 255);
|
||||
fill_rect(
|
||||
frame,
|
||||
width,
|
||||
x0 + MARKER_COLUMNS * cell,
|
||||
y0 - cell,
|
||||
cell,
|
||||
cell,
|
||||
0,
|
||||
);
|
||||
for bit in 0..MARKER_BITS {
|
||||
let col = bit % MARKER_COLUMNS;
|
||||
let row = bit / MARKER_COLUMNS;
|
||||
let value = if ((sequence >> bit) & 1) != 0 { 255 } else { 0 };
|
||||
fill_rect(
|
||||
frame,
|
||||
width,
|
||||
x0 + col * cell,
|
||||
y0 + row * cell,
|
||||
cell,
|
||||
cell,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_rect(frame: &mut [u8], width: usize, x0: usize, y0: usize, w: usize, h: usize, value: u8) {
|
||||
let pixels = frame.len() / 3;
|
||||
let height = pixels / width.max(1);
|
||||
let x1 = (x0 + w).min(width);
|
||||
let y1 = (y0 + h).min(height);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let offset = (y * width + x) * 3;
|
||||
if let Some(pixel) = frame.get_mut(offset..offset + 3) {
|
||||
pixel[0] = value;
|
||||
pixel[1] = value;
|
||||
pixel[2] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mode(value: &str) -> Result<(usize, usize, u32)> {
|
||||
let (size, fps) = value
|
||||
.split_once('@')
|
||||
.with_context(|| format!("mode must look like WIDTHxHEIGHT@FPS, got {value:?}"))?;
|
||||
let (width, height) = size
|
||||
.split_once('x')
|
||||
.with_context(|| format!("mode must look like WIDTHxHEIGHT@FPS, got {value:?}"))?;
|
||||
Ok((width.parse()?, height.parse()?, fps.parse()?))
|
||||
}
|
||||
|
||||
fn next_value(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
|
||||
it.next()
|
||||
.with_context(|| format!("{flag} requires a value"))
|
||||
}
|
||||
|
||||
fn parse_next<T>(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<T>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
T::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
Ok(next_value(it, flag)?.parse()?)
|
||||
}
|
||||
|
||||
fn duration_mul(duration: Duration, count: u64) -> Duration {
|
||||
Duration::from_nanos(
|
||||
duration
|
||||
.as_nanos()
|
||||
.saturating_mul(u128::from(count))
|
||||
.min(u128::from(u64::MAX)) as u64,
|
||||
)
|
||||
}
|
||||
|
||||
fn unix_millis() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
fn args_summary_json(args: &Args) -> String {
|
||||
format!(
|
||||
"{{\"schema\":\"lesavka.synthetic-uplink.v1\",\"server\":{server:?},\"width\":{width},\"height\":{height},\"fps\":{fps},\"duration_s\":{duration:.3},\"session_id\":{session}}}",
|
||||
server = args.server,
|
||||
width = args.width,
|
||||
height = args.height,
|
||||
fps = args.fps,
|
||||
duration = args.duration.as_secs_f64(),
|
||||
session = args.session_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"lesavka-synthetic-uplink\n\n\
|
||||
Sends sequence-coded synthetic MJPEG plus silent PCM through StreamWebcamMedia.\n\n\
|
||||
Options:\n\
|
||||
--server URL gRPC endpoint, default {DEFAULT_SERVER}\n\
|
||||
--mode WIDTHxHEIGHT@FPS shorthand for width/height/fps\n\
|
||||
--width N --height N --fps N\n\
|
||||
--duration SECONDS default 300\n\
|
||||
--jpeg-quality N default {DEFAULT_JPEG_QUALITY}\n\
|
||||
--artifact-dir PATH write command/summary metadata\n\
|
||||
--print-every N progress interval in frames"
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
// Contract coverage for the synthetic Theia-to-RCT UVC comparison probe.
|
||||
//
|
||||
// Scope: inspect the StreamWebcamMedia synthetic injector and the manual RCT
|
||||
// capture/comparison harness. Why: the remaining UVC corruption is rare and
|
||||
// stateful, so we need repeatable source-to-received frame evidence.
|
||||
|
||||
use std::{fs, path::PathBuf, process::Command};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const PROBE_SRC: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/manual/run_synthetic_rct_uvc_probe.py"
|
||||
));
|
||||
const INJECTOR_SRC: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/server/src/bin/lesavka-synthetic-uplink.rs"
|
||||
));
|
||||
const SERVER_CARGO: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/server/Cargo.toml"));
|
||||
const SERVER_INSTALL: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/scripts/install/server.sh"
|
||||
));
|
||||
|
||||
fn repo_script_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/manual/run_synthetic_rct_uvc_probe.py")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
||||
for expected in [
|
||||
"lesavka.synthetic-rct-capture.v1",
|
||||
"lesavka.synthetic-rct-probe.orchestrator.v1",
|
||||
"StreamWebcamMedia",
|
||||
"lesavka-synthetic-uplink",
|
||||
"--inject-host",
|
||||
"--rct-host",
|
||||
"--capture-only",
|
||||
"--source",
|
||||
"--mode",
|
||||
"1280x720@20,1280x720@30,1920x1080@20,1920x1080@30",
|
||||
"marker_decode_failed",
|
||||
"frame_repeat",
|
||||
"frame_gap",
|
||||
"frame_backwards",
|
||||
"lower_half_tear",
|
||||
"black_or_gray_slab",
|
||||
"horizontal_shift",
|
||||
"expected_",
|
||||
"suspicious_",
|
||||
"decoded_pct",
|
||||
"max_lower_mae",
|
||||
"ffmpeg",
|
||||
"v4l2",
|
||||
"x11grab",
|
||||
] {
|
||||
assert!(
|
||||
PROBE_SRC.contains(expected),
|
||||
"synthetic RCT probe should preserve marker {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_injector_enters_the_public_bundled_media_rpc() {
|
||||
for expected in [
|
||||
"name = \"lesavka-synthetic-uplink\"",
|
||||
"src/bin/lesavka-synthetic-uplink.rs",
|
||||
"stream_webcam_media(Request::new(ReceiverStream::new(rx)))",
|
||||
"UpstreamMediaBundle",
|
||||
"AudioEncoding::PcmS16le",
|
||||
"silence_pcm(args.frame_step_us())",
|
||||
"synthetic_rgb_frame",
|
||||
"draw_sequence_marker",
|
||||
"image/jpeg",
|
||||
"jpegenc",
|
||||
"client_capture_pts_us: pts_us",
|
||||
"client_send_pts_us: pts_us",
|
||||
] {
|
||||
assert!(
|
||||
SERVER_CARGO.contains(expected) || INJECTOR_SRC.contains(expected),
|
||||
"synthetic injector should preserve marker {expected}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
SERVER_INSTALL.contains(
|
||||
"install_verified_executable \"$SRC_DIR/target/release/lesavka-synthetic-uplink\" /usr/local/bin/lesavka-synthetic-uplink"
|
||||
),
|
||||
"server installer should install the synthetic uplink binary for Theia lab runs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_probe_self_test_detects_slab_and_shift_categories() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let output = Command::new("python3")
|
||||
.arg(repo_script_path())
|
||||
.arg("--self-test")
|
||||
.arg("--artifact-dir")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("run synthetic RCT probe self-test");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"synthetic probe self-test should succeed: stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let summary: Value = serde_json::from_str(
|
||||
&fs::read_to_string(dir.path().join("summary.json")).expect("summary json"),
|
||||
)
|
||||
.expect("parse summary json");
|
||||
assert_eq!(
|
||||
summary["schema"],
|
||||
"lesavka.synthetic-rct-probe.self-test.v1"
|
||||
);
|
||||
assert!(
|
||||
summary["suspicious_frames"].as_u64().unwrap_or_default() >= 2,
|
||||
"self-test should detect at least the slab and shifted synthetic frames: {summary}"
|
||||
);
|
||||
let records = summary["records"].as_array().expect("records array");
|
||||
assert!(
|
||||
records.iter().any(|record| record["reasons"]
|
||||
.as_array()
|
||||
.is_some_and(|reasons| reasons.iter().any(|reason| reason == "black_or_gray_slab"))),
|
||||
"self-test should include a slab category: {summary}"
|
||||
);
|
||||
assert!(
|
||||
records.iter().any(|record| record["reasons"]
|
||||
.as_array()
|
||||
.is_some_and(|reasons| reasons.iter().any(|reason| reason == "horizontal_shift"))),
|
||||
"self-test should include a horizontal shift category: {summary}"
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user