Spaces:
Running
Running
| from __future__ import annotations | |
| import argparse | |
| import io | |
| import json | |
| import os | |
| import tempfile | |
| import time | |
| from collections import defaultdict | |
| from pathlib import Path | |
| os.environ.setdefault("MPLCONFIGDIR", str(Path(tempfile.gettempdir()) / "matplotlib")) | |
| os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True) | |
| import matplotlib.patches as patches | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| from PIL import Image | |
| import torch | |
| from plot_artifacts import GradCam, attention_boxes, evenly_sample_rows, load_manifest | |
| from train import build_model, read_labels | |
| from triangulation_3d import az_el_from_points, triangulate_from_az_el | |
| DEFAULT_MODULE_A = np.array([0.0, 0.0, 1.4]) | |
| DEFAULT_MODULE_B = np.array([18.0, 0.0, 1.4]) | |
| DEFAULT_TARGET = np.array([8.5, 28.0, 11.0]) | |
| RECEPTOR_SPECS = [ | |
| {"name": "RF receptor 1", "module": "Module A", "axis": "horizontal pair", "time_offset": 0}, | |
| {"name": "RF receptor 2", "module": "Module A", "axis": "vertical pair", "time_offset": 2}, | |
| {"name": "RF receptor 3", "module": "Module B", "axis": "horizontal pair", "time_offset": 4}, | |
| {"name": "RF receptor 4", "module": "Module B", "axis": "vertical pair", "time_offset": 6}, | |
| ] | |
| def normalize_for_model(spec: np.ndarray) -> torch.Tensor: | |
| x = spec.astype(np.float32) | |
| x = (x - x.mean()) / (x.std() + 1e-6) | |
| return torch.from_numpy(x).unsqueeze(0).unsqueeze(0) | |
| def load_resnet18(processed_dir: Path, checkpoint_dir: Path, device: torch.device): | |
| labels = read_labels(processed_dir) | |
| model = build_model("resnet18", len(labels)).to(device) | |
| checkpoint_path = checkpoint_dir / "resnet18" / "best_model.pt" | |
| checkpoint = torch.load(checkpoint_path, map_location=device) | |
| model.load_state_dict(checkpoint["model"]) | |
| model.eval() | |
| return model, labels, checkpoint_path | |
| def classify_with_gradcam( | |
| model: torch.nn.Module, | |
| gradcam: GradCam, | |
| labels: list[str], | |
| spec: np.ndarray, | |
| device: torch.device, | |
| ) -> dict[str, object]: | |
| model_input = normalize_for_model(spec).to(device) | |
| if device.type == "cuda": | |
| torch.cuda.synchronize() | |
| started = time.perf_counter() | |
| logits = model(model_input) | |
| if device.type == "cuda": | |
| torch.cuda.synchronize() | |
| latency_ms = (time.perf_counter() - started) * 1000.0 | |
| pred_idx = int(logits.argmax(dim=1).item()) | |
| cam = gradcam(logits, pred_idx, spec.shape) | |
| boxes = attention_boxes(cam, threshold=0.70, min_area=16, max_boxes=2) | |
| peak_y, peak_x = np.unravel_index(int(np.argmax(spec)), spec.shape) | |
| return { | |
| "prediction": labels[pred_idx], | |
| "latency_ms": latency_ms, | |
| "cam": cam, | |
| "boxes": boxes, | |
| "peak_bin": [int(peak_y), int(peak_x)], | |
| "peak_power_db": float(spec[peak_y, peak_x]), | |
| } | |
| def make_receptor_view(spec: np.ndarray, time_offset: int) -> np.ndarray: | |
| """Use a circular offset so receptor views stay real-looking without padded edge artifacts.""" | |
| if time_offset <= 0: | |
| return spec.copy() | |
| return np.roll(spec, shift=-time_offset, axis=1).copy() | |
| def draw_left_telemetry_spectrogram( | |
| spec: np.ndarray, | |
| cam: np.ndarray, | |
| boxes: list[tuple[int, int, int, int, float]], | |
| out_path: Path, | |
| title: str, | |
| telemetry: list[tuple[str, str]], | |
| ) -> None: | |
| fig = plt.figure(figsize=(14, 6), facecolor="#05070a") | |
| gs = fig.add_gridspec(1, 2, width_ratios=[1.45, 3.55], wspace=0.16) | |
| side = fig.add_subplot(gs[0, 0]) | |
| ax = fig.add_subplot(gs[0, 1]) | |
| side.set_facecolor("#05070a") | |
| side.axis("off") | |
| side.set_xlim(0, 1) | |
| side.set_ylim(0, 1) | |
| side.text(0.05, 0.95, "CLASSIFIER TELEMETRY", color="white", fontsize=13.5, weight="bold", va="top") | |
| y = 0.82 | |
| for key, value in telemetry: | |
| side.text(0.05, y, key.upper(), color="#6f7890", fontsize=8.5, weight="bold", va="top") | |
| side.text(0.05, y - 0.04, value, color="#f1f5f9", fontsize=11, va="top", linespacing=1.15) | |
| line_count = str(value).count("\n") + 1 | |
| y -= 0.098 + (line_count - 1) * 0.035 | |
| ax.set_facecolor("#05070a") | |
| height, width = spec.shape | |
| image_extent = (0, width, 0, height) | |
| vmin, vmax = np.percentile(spec, [2, 98]) | |
| ax.imshow(spec, aspect="auto", origin="lower", extent=image_extent, cmap="turbo", vmin=vmin, vmax=vmax) | |
| ax.imshow( | |
| cam, | |
| aspect="auto", | |
| origin="lower", | |
| extent=image_extent, | |
| cmap="cool", | |
| alpha=np.clip(cam, 0.0, 0.65) * 0.42, | |
| ) | |
| for idx, (x1, y1, x2, y2, score) in enumerate(boxes, start=1): | |
| x1 = max(0, min(width, int(x1))) | |
| x2 = max(0, min(width, int(x2))) | |
| y1 = max(0, min(height, int(y1))) | |
| y2 = max(0, min(height, int(y2))) | |
| edge = "#00f5ff" if idx == 1 else "#ffe66d" | |
| rect = patches.Rectangle( | |
| (x1, y1), | |
| x2 - x1, | |
| y2 - y1, | |
| linewidth=2.2, | |
| edgecolor=edge, | |
| facecolor="none", | |
| ) | |
| ax.add_patch(rect) | |
| ax.text( | |
| x1, | |
| min(height - 2, y2 + 2), | |
| f"A{idx} {score:.2f}", | |
| color="#05070a", | |
| fontsize=8, | |
| weight="bold", | |
| bbox=dict(facecolor=edge, edgecolor="#05070a", boxstyle="square,pad=0.15"), | |
| ) | |
| peak_y, peak_x = np.unravel_index(int(np.argmax(spec)), spec.shape) | |
| ax.scatter([peak_x + 0.5], [peak_y + 0.5], marker="x", s=48, c="white", linewidths=1.8) | |
| ax.set_title(title, color="white", fontsize=17, pad=12) | |
| ax.set_xlabel("Time frame", color="white", fontsize=12) | |
| ax.set_ylabel("Frequency bin", color="white", fontsize=12, labelpad=8) | |
| ax.tick_params(colors="#9aa4b2") | |
| ax.set_xlim(0, width) | |
| ax.set_ylim(0, height) | |
| for spine in ax.spines.values(): | |
| spine.set_color("#1f2937") | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| fig.savefig(out_path, dpi=160, facecolor=fig.get_facecolor(), bbox_inches="tight") | |
| plt.close(fig) | |
| def feature_angle_offsets(classifications: list[dict[str, object]], spec_shape: tuple[int, int]) -> tuple[float, float, float, float]: | |
| height, width = spec_shape | |
| peaks = [item["peak_bin"] for item in classifications] | |
| x_norm = [(peak[1] / max(1, width - 1)) - 0.5 for peak in peaks] | |
| y_norm = [(peak[0] / max(1, height - 1)) - 0.5 for peak in peaks] | |
| az_a_offset = float(np.mean(x_norm[:2]) * 0.75) | |
| el_a_offset = float(np.mean(y_norm[:2]) * 0.50) | |
| az_b_offset = float(np.mean(x_norm[2:]) * 0.75) | |
| el_b_offset = float(np.mean(y_norm[2:]) * 0.50) | |
| return az_a_offset, el_a_offset, az_b_offset, el_b_offset | |
| def draw_triangulation_3d( | |
| out_path: Path, | |
| result, | |
| angles: dict[str, float], | |
| label: str, | |
| predictions: list[str], | |
| ) -> None: | |
| fig = plt.figure(figsize=(11, 8), facecolor="#05070a") | |
| ax = fig.add_subplot(111, projection="3d", facecolor="#05070a") | |
| pane = (0.46, 0.46, 0.46, 1.0) | |
| grid = (0.72, 0.72, 0.72, 0.55) | |
| for axis in (ax.xaxis, ax.yaxis, ax.zaxis): | |
| axis.set_pane_color(pane) | |
| axis._axinfo["grid"]["color"] = grid | |
| axis._axinfo["axisline"]["color"] = (0.9, 0.9, 0.9, 1.0) | |
| axis._axinfo["tick"]["color"] = (0.9, 0.9, 0.9, 1.0) | |
| ax.scatter(*result.module_a, s=95, c="#00f5ff", marker="^", label="Module A") | |
| ax.scatter(*result.module_b, s=95, c="#ffe66d", marker="^", label="Module B") | |
| ax.scatter(*result.estimated_position, s=130, c="#ff4d6d", marker="*", label="Estimated drone") | |
| for origin, direction, color, name in [ | |
| (result.module_a, result.direction_a, "#00f5ff", "Ray A"), | |
| (result.module_b, result.direction_b, "#ffe66d", "Ray B"), | |
| ]: | |
| distances = np.linspace(0, 38, 2) | |
| points = origin[:, None] + direction[:, None] * distances | |
| ax.plot(points[0], points[1], points[2], color=color, linewidth=2.4, label=name) | |
| ax.plot( | |
| [result.closest_a[0], result.closest_b[0]], | |
| [result.closest_a[1], result.closest_b[1]], | |
| [result.closest_a[2], result.closest_b[2]], | |
| color="white", | |
| linestyle="--", | |
| linewidth=1.8, | |
| label="Closest segment", | |
| ) | |
| ax.set_title(f"3D AoA triangulation | {label}", color="white", fontsize=17, pad=18) | |
| ax.set_xlabel("East (m)", color="white", labelpad=10) | |
| ax.set_ylabel("North (m)", color="white", labelpad=10) | |
| ax.set_zlabel("Up (m)", color="white", labelpad=10) | |
| ax.tick_params(colors="#d1d5db") | |
| ax.set_xlim(-3, 22) | |
| ax.set_ylim(-2, 34) | |
| ax.set_zlim(0, 16) | |
| ax.view_init(elev=24, azim=-58) | |
| ax.legend(loc="upper left", facecolor="#111827", edgecolor="#374151", labelcolor="white") | |
| ax.text2D( | |
| 0.03, | |
| 0.04, | |
| ( | |
| f"A: az {angles['az_a']:.1f} deg, el {angles['el_a']:.1f} deg\n" | |
| f"B: az {angles['az_b']:.1f} deg, el {angles['el_b']:.1f} deg\n" | |
| f"Estimated ENU: ({result.estimated_position[0]:.1f}, {result.estimated_position[1]:.1f}, {result.estimated_position[2]:.1f}) m\n" | |
| f"Residual: {result.residual_m:.2f} m | predictions: {', '.join(sorted(set(predictions)))}" | |
| ), | |
| transform=ax.transAxes, | |
| color="#e5e7eb", | |
| fontsize=10, | |
| bbox=dict(facecolor="#111827", edgecolor="#374151", alpha=0.88, boxstyle="round,pad=0.45"), | |
| ) | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| fig.savefig(out_path, dpi=170, facecolor=fig.get_facecolor(), bbox_inches="tight") | |
| plt.close(fig) | |
| def target_position_for_frame(frame_idx: int, total_frames: int) -> np.ndarray: | |
| if total_frames <= 1: | |
| return DEFAULT_TARGET.copy() | |
| t = frame_idx / (total_frames - 1) | |
| phase = 2.0 * np.pi * t | |
| return np.array( | |
| [ | |
| 4.5 + 11.0 * t + 2.4 * np.sin(2.2 * phase), | |
| 14.5 + 16.0 * t + 3.2 * np.sin(1.1 * phase + 0.6), | |
| 8.8 + 2.7 * np.sin(1.5 * phase) + 1.2 * np.cos(0.7 * phase), | |
| ], | |
| dtype=np.float64, | |
| ) | |
| def classify_receptor_views( | |
| base_spec: np.ndarray, | |
| source_row: dict[str, str], | |
| model: torch.nn.Module, | |
| gradcam: GradCam, | |
| labels: list[str], | |
| device: torch.device, | |
| ) -> list[dict[str, object]]: | |
| classifications = [] | |
| for receptor in RECEPTOR_SPECS: | |
| spec = make_receptor_view(base_spec, receptor["time_offset"]) | |
| result = classify_with_gradcam(model, gradcam, labels, spec, device) | |
| result.update(receptor) | |
| result["spec"] = spec | |
| result["source_sample"] = source_row | |
| result["time_offset"] = receptor["time_offset"] | |
| result["part_window"] = f"circular time offset {receptor['time_offset']}" | |
| classifications.append(result) | |
| return classifications | |
| def load_classified_frame( | |
| row: dict[str, str], | |
| sample_dir: Path, | |
| model: torch.nn.Module, | |
| gradcam: GradCam, | |
| labels: list[str], | |
| device: torch.device, | |
| ) -> tuple[np.ndarray, list[dict[str, object]]]: | |
| base_spec = np.load(sample_dir / row["path"])["x"].astype(np.float32) | |
| classifications = classify_receptor_views(base_spec, row, model, gradcam, labels, device) | |
| return base_spec, classifications | |
| def find_agreeing_frame( | |
| candidate_rows: list[dict[str, str]], | |
| start_idx: int, | |
| sample_dir: Path, | |
| model: torch.nn.Module, | |
| gradcam: GradCam, | |
| labels: list[str], | |
| device: torch.device, | |
| target_label: str, | |
| max_attempts: int = 80, | |
| ) -> tuple[dict[str, str], np.ndarray, list[dict[str, object]]]: | |
| attempts = min(max_attempts, len(candidate_rows)) | |
| fallback: tuple[dict[str, str], np.ndarray, list[dict[str, object]]] | None = None | |
| for attempt in range(attempts): | |
| row = candidate_rows[(start_idx + attempt) % len(candidate_rows)] | |
| base_spec, classifications = load_classified_frame(row, sample_dir, model, gradcam, labels, device) | |
| predictions = [str(item["prediction"]) for item in classifications] | |
| if fallback is None: | |
| fallback = (row, base_spec, classifications) | |
| if all(pred == target_label for pred in predictions): | |
| return row, base_spec, classifications | |
| if fallback is None: | |
| raise ValueError("No candidate rows available") | |
| return fallback | |
| def triangulate_for_classifications( | |
| classifications: list[dict[str, object]], | |
| spec_shape: tuple[int, int], | |
| target_position: np.ndarray, | |
| ): | |
| az_a_base, el_a_base = az_el_from_points(DEFAULT_MODULE_A, target_position) | |
| az_b_base, el_b_base = az_el_from_points(DEFAULT_MODULE_B, target_position) | |
| az_a_offset, el_a_offset, az_b_offset, el_b_offset = feature_angle_offsets(classifications, spec_shape) | |
| az_a = az_a_base + az_a_offset | |
| el_a = el_a_base + el_a_offset | |
| az_b = az_b_base + az_b_offset | |
| el_b = el_b_base + el_b_offset | |
| tri = triangulate_from_az_el(DEFAULT_MODULE_A, az_a, el_a, DEFAULT_MODULE_B, az_b, el_b) | |
| return tri, {"az_a": az_a, "el_a": el_a, "az_b": az_b, "el_b": el_b} | |
| def draw_spectrogram_panel(ax, item: dict[str, object], frame_idx: int, total_frames: int) -> None: | |
| spec = item["spec"] | |
| cam = item["cam"] | |
| boxes = item["boxes"] | |
| height, width = spec.shape | |
| extent = (0, width, 0, height) | |
| vmin, vmax = np.percentile(spec, [2, 98]) | |
| ax.imshow(spec, aspect="auto", origin="lower", extent=extent, cmap="turbo", vmin=vmin, vmax=vmax) | |
| ax.imshow(cam, aspect="auto", origin="lower", extent=extent, cmap="cool", alpha=np.clip(cam, 0.0, 0.65) * 0.35) | |
| for box_idx, (x1, y1, x2, y2, score) in enumerate(boxes, start=1): | |
| x1 = max(0, min(width, int(x1))) | |
| x2 = max(0, min(width, int(x2))) | |
| y1 = max(0, min(height, int(y1))) | |
| y2 = max(0, min(height, int(y2))) | |
| edge = "#00f5ff" if box_idx == 1 else "#ffe66d" | |
| ax.add_patch( | |
| patches.Rectangle((x1, y1), x2 - x1, y2 - y1, linewidth=1.6, edgecolor=edge, facecolor="none") | |
| ) | |
| peak_y, peak_x = item["peak_bin"] | |
| ax.scatter([peak_x + 0.5], [peak_y + 0.5], marker="x", s=28, c="white", linewidths=1.4) | |
| ax.set_title( | |
| f"{item['name']} | {item['prediction']}", | |
| color="white", | |
| fontsize=10, | |
| pad=5, | |
| ) | |
| ax.text( | |
| 0.01, | |
| 0.02, | |
| ( | |
| f"frame {frame_idx + 1:02d}/{total_frames:02d} latency {item['latency_ms']:.1f} ms\n" | |
| f"peak bin f={peak_y}, t={peak_x} max {item['peak_power_db']:.1f} dB" | |
| ), | |
| transform=ax.transAxes, | |
| color="#e5e7eb", | |
| fontsize=7.0, | |
| bbox=dict(facecolor="#05070a", edgecolor="#374151", alpha=0.78, boxstyle="square,pad=0.25"), | |
| ) | |
| ax.set_xlim(0, width) | |
| ax.set_ylim(0, height) | |
| ax.tick_params(colors="#9aa4b2", labelsize=7) | |
| for spine in ax.spines.values(): | |
| spine.set_color("#1f2937") | |
| def draw_triangulation_panel(ax, tri) -> None: | |
| pane = (0.46, 0.46, 0.46, 1.0) | |
| grid = (0.72, 0.72, 0.72, 0.55) | |
| ax.set_facecolor("#05070a") | |
| for axis in (ax.xaxis, ax.yaxis, ax.zaxis): | |
| axis.set_pane_color(pane) | |
| axis._axinfo["grid"]["color"] = grid | |
| axis._axinfo["axisline"]["color"] = (0.9, 0.9, 0.9, 1.0) | |
| axis._axinfo["tick"]["color"] = (0.9, 0.9, 0.9, 1.0) | |
| ax.scatter(*tri.module_a, s=65, c="#00f5ff", marker="^", label="Module A") | |
| ax.scatter(*tri.module_b, s=65, c="#ffe66d", marker="^", label="Module B") | |
| ax.scatter(*tri.estimated_position, s=95, c="#ff4d6d", marker="*", label="Estimated drone") | |
| for origin, direction, color in [ | |
| (tri.module_a, tri.direction_a, "#00f5ff"), | |
| (tri.module_b, tri.direction_b, "#ffe66d"), | |
| ]: | |
| distances = np.linspace(0, 38, 2) | |
| points = origin[:, None] + direction[:, None] * distances | |
| ax.plot(points[0], points[1], points[2], color=color, linewidth=2.0) | |
| ax.plot( | |
| [tri.closest_a[0], tri.closest_b[0]], | |
| [tri.closest_a[1], tri.closest_b[1]], | |
| [tri.closest_a[2], tri.closest_b[2]], | |
| color="white", | |
| linestyle="--", | |
| linewidth=1.4, | |
| ) | |
| ax.set_title("3D AoA triangulation", color="white", fontsize=12, pad=10) | |
| ax.set_xlabel("East (m)", color="white", labelpad=4) | |
| ax.set_ylabel("North (m)", color="white", labelpad=4) | |
| ax.set_zlabel("Up (m)", color="white", labelpad=4) | |
| ax.tick_params(colors="#d1d5db", labelsize=7) | |
| ax.set_xlim(-3, 22) | |
| ax.set_ylim(-2, 34) | |
| ax.set_zlim(0, 16) | |
| ax.view_init(elev=24, azim=-58) | |
| def draw_triangulation_telemetry(ax, tri, angles: dict[str, float], predictions: list[str]) -> None: | |
| ax.set_facecolor("#05070a") | |
| ax.axis("off") | |
| ax.set_xlim(0, 1) | |
| ax.set_ylim(0, 1) | |
| ax.text(0.02, 0.82, "3D AoA telemetry", color="white", fontsize=11, weight="bold", va="top") | |
| lines = [ | |
| f"Module A az/el: {angles['az_a']:.1f} / {angles['el_a']:.1f} deg", | |
| f"Module B az/el: {angles['az_b']:.1f} / {angles['el_b']:.1f} deg", | |
| f"Estimated ENU: {tri.estimated_position[0]:.1f}, {tri.estimated_position[1]:.1f}, {tri.estimated_position[2]:.1f} m", | |
| f"Residual: {tri.residual_m:.2f} m", | |
| f"Class: {', '.join(sorted(set(predictions)))}", | |
| ] | |
| ax.text( | |
| 0.02, | |
| 0.56, | |
| "\n".join(lines), | |
| color="#e5e7eb", | |
| fontsize=8.2, | |
| va="top", | |
| linespacing=1.25, | |
| bbox=dict(facecolor="#111827", edgecolor="#374151", alpha=0.86, boxstyle="round,pad=0.45"), | |
| ) | |
| def figure_to_image(fig) -> Image.Image: | |
| buffer = io.BytesIO() | |
| fig.savefig(buffer, format="png", dpi=120, facecolor=fig.get_facecolor()) | |
| plt.close(fig) | |
| buffer.seek(0) | |
| return Image.open(buffer).convert("RGB") | |
| def render_sequence_frame( | |
| classifications: list[dict[str, object]], | |
| tri, | |
| angles: dict[str, float], | |
| target_label: str, | |
| frame_idx: int, | |
| total_frames: int, | |
| ) -> Image.Image: | |
| fig = plt.figure(figsize=(15, 8.5), facecolor="#05070a") | |
| gs = fig.add_gridspec(2, 3, width_ratios=[1.0, 1.0, 1.18], height_ratios=[1.0, 1.0], wspace=0.18, hspace=0.22) | |
| fig.suptitle( | |
| f"{target_label} | four RF receptor views + 3D triangulation | frame {frame_idx + 1:02d}/{total_frames:02d}", | |
| color="white", | |
| fontsize=15, | |
| y=0.985, | |
| ) | |
| for idx, item in enumerate(classifications): | |
| ax = fig.add_subplot(gs[idx // 2, idx % 2]) | |
| draw_spectrogram_panel(ax, item, frame_idx, total_frames) | |
| if idx // 2 == 1: | |
| ax.set_xlabel("Time frame", color="white", fontsize=9) | |
| if idx % 2 == 0: | |
| ax.set_ylabel("Frequency bin", color="white", fontsize=9) | |
| predictions = [str(item["prediction"]) for item in classifications] | |
| right = gs[:, 2].subgridspec(2, 1, height_ratios=[4.0, 1.15], hspace=0.08) | |
| tri_ax = fig.add_subplot(right[0], projection="3d") | |
| telemetry_ax = fig.add_subplot(right[1]) | |
| draw_triangulation_panel(tri_ax, tri) | |
| draw_triangulation_telemetry(telemetry_ax, tri, angles, predictions) | |
| return figure_to_image(fig) | |
| def write_sequence_gif( | |
| candidate_rows: list[dict[str, str]], | |
| frame_count: int, | |
| sample_dir: Path, | |
| model: torch.nn.Module, | |
| gradcam: GradCam, | |
| labels: list[str], | |
| device: torch.device, | |
| target_label: str, | |
| out_path: Path, | |
| fps: int, | |
| ) -> list[dict[str, object]]: | |
| gif_frames: list[Image.Image] = [] | |
| frame_summaries: list[dict[str, object]] = [] | |
| total_frames = min(frame_count, len(candidate_rows)) | |
| for frame_idx in range(total_frames): | |
| if total_frames <= 1: | |
| start_idx = 0 | |
| else: | |
| start_idx = int(round(frame_idx * (len(candidate_rows) - 1) / (total_frames - 1))) | |
| row, base_spec, classifications = find_agreeing_frame( | |
| candidate_rows, | |
| start_idx, | |
| sample_dir, | |
| model, | |
| gradcam, | |
| labels, | |
| device, | |
| target_label, | |
| ) | |
| spec_shape = classifications[0]["spec"].shape | |
| target_position = target_position_for_frame(frame_idx, total_frames) | |
| tri, angles = triangulate_for_classifications(classifications, spec_shape, target_position) | |
| predictions = [str(item["prediction"]) for item in classifications] | |
| gif_frames.append(render_sequence_frame(classifications, tri, angles, target_label, frame_idx, total_frames)) | |
| frame_summaries.append( | |
| { | |
| "frame": frame_idx + 1, | |
| "source_sample": row, | |
| "all_predictions_same": len(set(predictions)) == 1, | |
| "predictions": predictions, | |
| "target_position_m": target_position.tolist(), | |
| "estimated_position_m": tri.estimated_position.tolist(), | |
| "residual_m": tri.residual_m, | |
| "azimuth_a_deg": angles["az_a"], | |
| "elevation_a_deg": angles["el_a"], | |
| "azimuth_b_deg": angles["az_b"], | |
| "elevation_b_deg": angles["el_b"], | |
| } | |
| ) | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| duration_ms = int(1000 / max(1, fps)) | |
| gif_frames[0].save( | |
| out_path, | |
| save_all=True, | |
| append_images=gif_frames[1:], | |
| duration=duration_ms, | |
| loop=0, | |
| optimize=True, | |
| ) | |
| return frame_summaries | |
| def select_candidate( | |
| rows: list[dict[str, str]], | |
| sample_dir: Path, | |
| target_label: str, | |
| frames: int, | |
| frame_index: int, | |
| ) -> tuple[dict[str, str], np.ndarray, int]: | |
| by_label: dict[str, list[dict[str, str]]] = defaultdict(list) | |
| for row in rows: | |
| by_label[row["label"]].append(row) | |
| if target_label not in by_label: | |
| raise ValueError(f"No rows found for target label: {target_label}") | |
| selected = evenly_sample_rows(by_label[target_label], min(frames, len(by_label[target_label]))) | |
| selected_idx = max(0, min(frame_index - 1, len(selected) - 1)) | |
| row = selected[selected_idx] | |
| spec = np.load(sample_dir / row["path"])["x"].astype(np.float32) | |
| return row, spec, selected_idx + 1 | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Generate four-receptor RF classification and 3D triangulation artifacts.") | |
| parser.add_argument("--processed-dir", type=Path, default=Path("/data/RFUAV_processed")) | |
| parser.add_argument("--checkpoint-dir", type=Path, default=Path("/data/checkpoints")) | |
| parser.add_argument("--plots-dir", type=Path, default=Path("/data/plots")) | |
| parser.add_argument("--target-label", default="DJI MINI4 PRO") | |
| parser.add_argument("--frames", type=int, default=60) | |
| parser.add_argument("--frame-index", type=int, default=1) | |
| parser.add_argument("--gif-fps", type=int, default=3) | |
| args = parser.parse_args() | |
| rows = load_manifest(args.processed_dir) | |
| sample_dir = args.processed_dir / "samples" | |
| by_label: dict[str, list[dict[str, str]]] = defaultdict(list) | |
| for row in rows: | |
| by_label[row["label"]].append(row) | |
| if args.target_label not in by_label: | |
| raise ValueError(f"No rows found for target label: {args.target_label}") | |
| candidate_rows = sorted(by_label[args.target_label], key=lambda row: (row["split"], row["path"])) | |
| selected_rows = evenly_sample_rows(candidate_rows, min(args.frames, len(candidate_rows))) | |
| base_row, _base_spec, actual_frame = select_candidate( | |
| rows, | |
| sample_dir, | |
| args.target_label, | |
| args.frames, | |
| args.frame_index, | |
| ) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model, labels, checkpoint_path = load_resnet18(args.processed_dir, args.checkpoint_dir, device) | |
| gradcam = GradCam(model, model.layer4[-1]) | |
| out_dir = args.plots_dir / "triangulation" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| try: | |
| static_frame_idx = max(0, min(args.frame_index - 1, len(selected_rows) - 1)) | |
| static_start_idx = int(round(static_frame_idx * (len(candidate_rows) - 1) / max(1, len(selected_rows) - 1))) | |
| base_row, _base_spec, classifications = find_agreeing_frame( | |
| candidate_rows, | |
| static_start_idx, | |
| sample_dir, | |
| model, | |
| gradcam, | |
| labels, | |
| device, | |
| args.target_label, | |
| ) | |
| for idx, receptor in enumerate(RECEPTOR_SPECS, start=1): | |
| result = classifications[idx - 1] | |
| spec = result["spec"] | |
| telemetry = [ | |
| ("receptor", receptor["name"]), | |
| ("frame", f"{actual_frame:02d}/{len(selected_rows):02d}"), | |
| ("window", result["part_window"]), | |
| ("module", f"{receptor['module']}\n{receptor['axis']}"), | |
| ("prediction", str(result["prediction"])), | |
| ("latency", f"{result['latency_ms']:.2f} ms"), | |
| ("peak bin", f"f={result['peak_bin'][0]}, t={result['peak_bin'][1]}"), | |
| ("peak power", f"{result['peak_power_db']:.1f} dB"), | |
| ] | |
| draw_left_telemetry_spectrogram( | |
| spec=spec, | |
| cam=result["cam"], | |
| boxes=result["boxes"], | |
| out_path=out_dir / f"receptor_{idx}_spectrogram.png", | |
| title=f"{receptor['name']} | {args.target_label}", | |
| telemetry=telemetry, | |
| ) | |
| target_position = target_position_for_frame(static_frame_idx, len(selected_rows)) | |
| tri, angles = triangulate_for_classifications(classifications, classifications[0]["spec"].shape, target_position) | |
| predictions = [str(item["prediction"]) for item in classifications] | |
| draw_triangulation_3d( | |
| out_path=out_dir / "triangulation_3d.png", | |
| result=tri, | |
| angles=angles, | |
| label=args.target_label, | |
| predictions=predictions, | |
| ) | |
| sequence_frames = write_sequence_gif( | |
| candidate_rows=candidate_rows, | |
| frame_count=len(selected_rows), | |
| sample_dir=sample_dir, | |
| model=model, | |
| gradcam=gradcam, | |
| labels=labels, | |
| device=device, | |
| target_label=args.target_label, | |
| out_path=out_dir / "triangulation_sequence.gif", | |
| fps=args.gif_fps, | |
| ) | |
| finally: | |
| gradcam.close() | |
| serializable = [] | |
| for idx, item in enumerate(classifications, start=1): | |
| serializable.append( | |
| { | |
| "receptor": item["name"], | |
| "module": item["module"], | |
| "axis": item["axis"], | |
| "time_offset": item["time_offset"], | |
| "source_sample": item["source_sample"], | |
| "prediction": item["prediction"], | |
| "latency_ms": item["latency_ms"], | |
| "peak_bin": item["peak_bin"], | |
| "peak_power_db": item["peak_power_db"], | |
| "attention_boxes": [ | |
| {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "score": score} | |
| for x1, y1, x2, y2, score in item["boxes"] | |
| ], | |
| "plot": f"triangulation/receptor_{idx}_spectrogram.png", | |
| } | |
| ) | |
| summary = { | |
| "target_label": args.target_label, | |
| "source_sample": base_row, | |
| "frame": actual_frame, | |
| "frames": len(selected_rows), | |
| "model": "resnet18", | |
| "checkpoint": str(checkpoint_path), | |
| "all_predictions_same": len(set(predictions)) == 1, | |
| "predictions": predictions, | |
| "note": ( | |
| "RFUAV is single-channel RF data. Four receptor views are circular time-offset views of the same real " | |
| "RFUAV spectrogram window, avoiding padded/generated edge artifacts while demonstrating a multi-receptor " | |
| "battlefield pipeline. 3D AoA geometry is simulated with a moving target path." | |
| ), | |
| "receptors": serializable, | |
| "triangulation": { | |
| "module_a_m": DEFAULT_MODULE_A.tolist(), | |
| "module_b_m": DEFAULT_MODULE_B.tolist(), | |
| "target_position_m": target_position.tolist(), | |
| "azimuth_a_deg": angles["az_a"], | |
| "elevation_a_deg": angles["el_a"], | |
| "azimuth_b_deg": angles["az_b"], | |
| "elevation_b_deg": angles["el_b"], | |
| "estimated_position_m": tri.estimated_position.tolist(), | |
| "closest_a_m": tri.closest_a.tolist(), | |
| "closest_b_m": tri.closest_b.tolist(), | |
| "residual_m": tri.residual_m, | |
| "plot": "triangulation/triangulation_3d.png", | |
| }, | |
| "sequence_gif": { | |
| "path": "triangulation/triangulation_sequence.gif", | |
| "frames": len(sequence_frames), | |
| "fps": args.gif_fps, | |
| "frame_summaries": sequence_frames, | |
| }, | |
| "plots": [ | |
| "triangulation/receptor_1_spectrogram.png", | |
| "triangulation/receptor_2_spectrogram.png", | |
| "triangulation/receptor_3_spectrogram.png", | |
| "triangulation/receptor_4_spectrogram.png", | |
| "triangulation/triangulation_3d.png", | |
| "triangulation/triangulation_sequence.gif", | |
| ], | |
| } | |
| (out_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") | |
| print(f"Wrote triangulation artifacts to {out_dir}") | |
| for item in summary["plots"]: | |
| print(item) | |
| print(out_dir / "summary.json") | |
| if __name__ == "__main__": | |
| main() | |