"""blackout-spoof-derkachi — synchronized visual blackout + GPS spoof (FT-N-04, NFT-RES-04). Produces a **schedule** + paired runtime artefacts for a coordinated visual-blackout / FC-GPS-spoof scenario. The schedule itself is the single source of truth — the video-overlay portion AND the FC-inbound proxy patch both read from it so the two streams stay synchronized within AC-3 (≤40 ms wall-clock alignment). What ``build()`` writes: / schedule.json # window_start_ms / window_end_ms, # spoofed-GPS frame timeline frames/AD000001.jpg # source frame, OR a black frame inside windows … manifest.csv # per-replaced-frame metadata for tests summary.json # aggregate (window count, max alignment err, …) The schedule's ``spoof_gps`` list is consumed by ``fc_proxy.py`` at run time: the proxy walks its monotonic clock and, when ``now_ms`` falls inside ``[window_start_ms, window_end_ms]``, replaces inbound GPS frames with the next pre-computed spoofed record. Determinism (AC-1 of AZ-408): identical ``(window_seconds, spoof_offset_m, spoof_bearing_deg, seed)`` reproduce the same schedule and frame outputs. Spoof-GPS values come from a ``derive_rng("blackout_spoof", …)`` stream; window timing is deterministic-positional (anchored at 30 % of the source duration so each window family ends inside the flight). The 200–500 m inter-spoof delta requirement (AC-4 / AC-NEW-8) is enforced by the delta-bound parameter — no random rejection sampling. Public-boundary discipline: this module does NOT import any ``src/gps_denied_onboard`` symbol. """ from __future__ import annotations import argparse import csv import io import json import logging import math import shutil import sys from dataclasses import dataclass, field from pathlib import Path import numpy as np from ._common import derive_rng, tmpfs_root logger = logging.getLogger(__name__) # AC-NEW-8: spoofed GPS jumps 200-500 m between consecutive spoof frames. _MIN_INTER_SPOOF_DELTA_M = 200.0 _MAX_INTER_SPOOF_DELTA_M = 500.0 # Spoofed-frame cadence — typical FC GPS update rate (10 Hz). _SPOOF_HZ = 10.0 # AC-4: spoofed fields stay inside typical-flight ranges. _SPOOF_FIX_TYPES = (3, 4) # GPS_FIX_TYPE_3D / GPS_FIX_TYPE_DGPS _SPOOF_HDOP_RANGE = (0.5, 2.5) # Source-frame defaults — overrideable via CLI. _DEFAULT_SRC_FPS = 30.0 _TILE_W = 256 _TILE_H = 256 @dataclass(frozen=True) class BlackoutSpoofPlan: """Configuration for the blackout-spoof-derkachi fixture. AZ-408 replaces the AZ-406 scaffold dataclass; the previous shape (``blackout_seconds`` / ``spoof_offset_m`` / ``spoof_bearing_deg``) is preserved and extended with the inputs the runtime build path needs. """ source_frames_dir: Path blackout_seconds: float seed: int = 0 spoof_offset_m: float = 350.0 spoof_bearing_deg: float = 45.0 source_fps: float = _DEFAULT_SRC_FPS # AC-NEW-3: the proxy must START emitting spoofed GPS within ≤40 ms # of the first all-black video frame. This is a documented invariant # the runtime proxy enforces; we keep it in the plan as the # "promised" alignment so tests can assert against it. max_alignment_err_ms: float = 40.0 initial_lat_deg: float = 50.075 initial_lon_deg: float = 36.15 @dataclass(frozen=True) class SpoofGpsFrame: """One spoofed GPS record — what fc_proxy will inject in place of real GPS.""" monotonic_ms: int lat_deg: float lon_deg: float alt_m: float fix_type: int hdop: float @dataclass(frozen=True) class BlackoutSpoofSchedule: """The full coordinated timeline written to ``schedule.json``.""" window_start_ms: int window_end_ms: int spoof_gps: list[SpoofGpsFrame] = field(default_factory=list) blackout_frame_indices: list[int] = field(default_factory=list) max_alignment_err_ms: float = 40.0 @dataclass(frozen=True) class BlackoutSpoofReport: """Summary of a single ``build()`` run — written to ``summary.json``.""" out_root: Path schedule: BlackoutSpoofSchedule blackout_frame_count: int spoof_frame_count: int inter_spoof_delta_m_min: float inter_spoof_delta_m_max: float def _bearing_offset(lat: float, lon: float, bearing_deg: float, dist_m: float) -> tuple[float, float]: """Project ``(lat, lon)`` along ``bearing_deg`` by ``dist_m`` (great-circle).""" R = 6_371_000.0 br = math.radians(bearing_deg) lat1 = math.radians(lat) lon1 = math.radians(lon) ang = dist_m / R lat2 = math.asin(math.sin(lat1) * math.cos(ang) + math.cos(lat1) * math.sin(ang) * math.cos(br)) lon2 = lon1 + math.atan2( math.sin(br) * math.sin(ang) * math.cos(lat1), math.cos(ang) - math.sin(lat1) * math.sin(lat2), ) return math.degrees(lat2), math.degrees(lon2) def _build_spoof_gps_track( plan: BlackoutSpoofPlan, window_start_ms: int, window_end_ms: int, rng: np.random.Generator, ) -> list[SpoofGpsFrame]: """Generate a spoofed-GPS track that satisfies AC-4 + AC-NEW-8. The track starts at the plan's initial point + spoof_offset_m along spoof_bearing_deg (the initial "jump" that defines the spoofed position). Subsequent frames jump 200-500 m in a randomly-perturbed bearing each step — enforced deterministically by the seeded RNG. """ cadence_ms = int(round(1000.0 / _SPOOF_HZ)) frames: list[SpoofGpsFrame] = [] cur_lat, cur_lon = _bearing_offset( plan.initial_lat_deg, plan.initial_lon_deg, plan.spoof_bearing_deg, plan.spoof_offset_m ) cur_alt = 300.0 # plausible-cruise altitude (matches `flight_derkachi/camera_info.md`) cur_bearing = plan.spoof_bearing_deg t = window_start_ms while t <= window_end_ms: delta_m = float( rng.uniform(_MIN_INTER_SPOOF_DELTA_M, _MAX_INTER_SPOOF_DELTA_M) ) # Perturb bearing ±60° per step so the spoofed track looks like # a realistic-but-bad GPS noise pattern (not a straight line). cur_bearing = (cur_bearing + float(rng.uniform(-60.0, 60.0))) % 360.0 cur_lat, cur_lon = _bearing_offset(cur_lat, cur_lon, cur_bearing, delta_m) # Stay inside realistic flight altitude range; small noise only. cur_alt += float(rng.uniform(-2.0, 2.0)) fix_type = int(rng.choice(_SPOOF_FIX_TYPES)) hdop = float(rng.uniform(*_SPOOF_HDOP_RANGE)) frames.append( SpoofGpsFrame( monotonic_ms=t, lat_deg=round(cur_lat, 7), lon_deg=round(cur_lon, 7), alt_m=round(cur_alt, 3), fix_type=fix_type, hdop=round(hdop, 3), ) ) t += cadence_ms return frames def _black_jpeg_bytes() -> bytes: """All-black 256×256 JPEG using the project's pinned PIL settings.""" from PIL import Image # noqa: PLC0415 — heavy import, deferred img = Image.new("RGB", (_TILE_W, _TILE_H), color=(0, 0, 0)) buf = io.BytesIO() img.save( buf, format="JPEG", quality=85, optimize=False, progressive=False, subsampling=2, ) return buf.getvalue() def build(plan: BlackoutSpoofPlan, out_root: Path) -> BlackoutSpoofReport: """Generate the blackout-spoof-derkachi fixture under ``out_root``.""" if plan.blackout_seconds <= 0: raise ValueError(f"blackout_seconds must be > 0; got {plan.blackout_seconds}") if out_root.exists(): shutil.rmtree(out_root) (out_root / "frames").mkdir(parents=True) src_dir = plan.source_frames_dir if not src_dir.is_dir(): raise FileNotFoundError(f"source frames directory not found: {src_dir}") frames = sorted(src_dir.glob("AD*.jpg")) if not frames: raise FileNotFoundError(f"no AD*.jpg frames under {src_dir}") total_frames = len(frames) src_duration_ms = int(round((total_frames / plan.source_fps) * 1000.0)) # Anchor the window at 30 % of the source duration. The window must # fit inside the source — if the requested blackout is longer than # the remaining flight, fall back to "blackout from 30 % to end". window_start_ms = int(0.3 * src_duration_ms) window_end_ms = min( window_start_ms + int(plan.blackout_seconds * 1000), src_duration_ms ) # Frame-index window in the source frame-stream (frames are at # ``source_fps`` Hz so a window of ``W`` ms maps to ``W/1000 * fps`` # frames). first_blackout_frame = int(round(window_start_ms / 1000.0 * plan.source_fps)) last_blackout_frame = int(round(window_end_ms / 1000.0 * plan.source_fps)) blackout_indices = list(range(first_blackout_frame, min(last_blackout_frame, total_frames))) rng = derive_rng( "blackout_spoof", plan.seed, plan.blackout_seconds, plan.spoof_offset_m, plan.spoof_bearing_deg, ) spoof_frames = _build_spoof_gps_track(plan, window_start_ms, window_end_ms, rng) schedule = BlackoutSpoofSchedule( window_start_ms=window_start_ms, window_end_ms=window_end_ms, spoof_gps=spoof_frames, blackout_frame_indices=blackout_indices, max_alignment_err_ms=plan.max_alignment_err_ms, ) black_jpeg = _black_jpeg_bytes() manifest_rows: list[dict] = [] blackout_set = set(blackout_indices) for frame_idx, frame_path in enumerate(frames): out_path = out_root / "frames" / frame_path.name if frame_idx in blackout_set: out_path.write_bytes(black_jpeg) manifest_rows.append( { "frame_idx": frame_idx, "src_jpeg_path": frame_path.name, "kind": "blackout", "window_start_ms": window_start_ms, "window_end_ms": window_end_ms, "seed": plan.seed, } ) else: shutil.copy2(frame_path, out_path) _write_schedule(out_root, schedule) _write_manifest(out_root, manifest_rows) deltas_m: list[float] = [] for prev, nxt in zip(spoof_frames, spoof_frames[1:]): from ._common import haversine_m as _hav deltas_m.append(_hav(prev.lat_deg, prev.lon_deg, nxt.lat_deg, nxt.lon_deg)) report = BlackoutSpoofReport( out_root=out_root, schedule=schedule, blackout_frame_count=len(blackout_indices), spoof_frame_count=len(spoof_frames), inter_spoof_delta_m_min=min(deltas_m) if deltas_m else 0.0, inter_spoof_delta_m_max=max(deltas_m) if deltas_m else 0.0, ) _write_summary(out_root, report) return report def _write_schedule(out_root: Path, schedule: BlackoutSpoofSchedule) -> None: payload = { "window_start_ms": schedule.window_start_ms, "window_end_ms": schedule.window_end_ms, "max_alignment_err_ms": schedule.max_alignment_err_ms, "blackout_frame_indices": schedule.blackout_frame_indices, "spoof_gps": [ { "monotonic_ms": f.monotonic_ms, "lat_deg": f.lat_deg, "lon_deg": f.lon_deg, "alt_m": f.alt_m, "fix_type": f.fix_type, "hdop": f.hdop, } for f in schedule.spoof_gps ], } (out_root / "schedule.json").write_text( json.dumps(payload, sort_keys=True, indent=2) + "\n" ) def _write_manifest(out_root: Path, rows: list[dict]) -> None: manifest = out_root / "manifest.csv" with manifest.open("w", newline="") as fp: writer = csv.DictWriter( fp, fieldnames=["frame_idx", "src_jpeg_path", "kind", "window_start_ms", "window_end_ms", "seed"], lineterminator="\n", ) writer.writeheader() for row in sorted(rows, key=lambda r: r["frame_idx"]): writer.writerow(row) def _write_summary(out_root: Path, report: BlackoutSpoofReport) -> None: payload = { "scenario": "blackout-spoof-derkachi", "window_start_ms": report.schedule.window_start_ms, "window_end_ms": report.schedule.window_end_ms, "blackout_frame_count": report.blackout_frame_count, "spoof_frame_count": report.spoof_frame_count, "inter_spoof_delta_m_min": round(report.inter_spoof_delta_m_min, 3), "inter_spoof_delta_m_max": round(report.inter_spoof_delta_m_max, 3), "max_alignment_err_ms": report.schedule.max_alignment_err_ms, } (out_root / "summary.json").write_text( json.dumps(payload, sort_keys=True, indent=2) + "\n" ) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Blackout + spoofed-GPS injection (FT-N-04)") parser.add_argument("--source-frames", type=Path, required=True) parser.add_argument( "--window-seconds", type=float, required=True, help="Blackout window length in seconds (5/15/35 for FT-N-04 / NFT-RES-04 family)", ) parser.add_argument("--seed", type=int, default=0) parser.add_argument("--spoof-offset-m", type=float, default=350.0) parser.add_argument("--spoof-bearing-deg", type=float, default=45.0) parser.add_argument("--source-fps", type=float, default=_DEFAULT_SRC_FPS) parser.add_argument( "--out-root", type=Path, default=None, help="Output dir. If omitted, /tmp//blackout-spoof-s/.", ) parser.add_argument("--run-id", default="local") parser.add_argument("--quiet", action="store_true") args = parser.parse_args(argv) logging.basicConfig( level=logging.WARNING if args.quiet else logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) out_root = args.out_root or tmpfs_root( args.run_id, f"blackout-spoof-{int(args.window_seconds)}s" ) plan = BlackoutSpoofPlan( source_frames_dir=args.source_frames, blackout_seconds=args.window_seconds, seed=args.seed, spoof_offset_m=args.spoof_offset_m, spoof_bearing_deg=args.spoof_bearing_deg, source_fps=args.source_fps, ) report = build(plan, out_root) summary = { "scenario": "blackout-spoof-derkachi", "out_root": str(report.out_root), "window_start_ms": report.schedule.window_start_ms, "window_end_ms": report.schedule.window_end_ms, "blackout_frame_count": report.blackout_frame_count, "spoof_frame_count": report.spoof_frame_count, "inter_spoof_delta_m_min": round(report.inter_spoof_delta_m_min, 3), "inter_spoof_delta_m_max": round(report.inter_spoof_delta_m_max, 3), "max_alignment_err_ms": report.schedule.max_alignment_err_ms, } json.dump(summary, sys.stdout, sort_keys=True, indent=2) sys.stdout.write("\n") return 0 if __name__ == "__main__": raise SystemExit(main())