| |
| """ |
| NQR-SNN Detector — Gradio UI |
| ============================== |
| Drag-and-drop any file format → ¹⁴N compound detection. |
| |
| Supports: CSV, NPY, NPZ, MAT, HDF5, WAV, JSON, Parquet, Excel, |
| binary IQ (cf32/cs16/cu8), SigMF, JCAMP-DX, Bruker FID, |
| plain text, and more. |
| |
| Usage: |
| python app.py # Launch locally |
| python app.py --share # Public URL |
| python app.py --port 7861 # Custom port |
| """ |
|
|
| import os |
| import sys |
| import tempfile |
| import time |
| import numpy as np |
| import pandas as pd |
|
|
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
|
|
| from nqr_snn import config |
| from nqr_snn.data.ingest import load_signals, detect_format, PARSERS |
| from nqr_snn.data.dataset import extract_features_batch |
| from nqr_snn.snn.model import SpikingClassifier |
| from nqr_snn.snn.ensemble import SNNEnsemble |
|
|
| |
| DETECTOR = None |
|
|
|
|
| def get_detector(): |
| """Lazy-load the detector.""" |
| global DETECTOR |
| if DETECTOR is None: |
| from nqr_snn.inference import NQRDetector |
| DETECTOR = NQRDetector(ensemble_size=config.ENSEMBLE_SIZE) |
| return DETECTOR |
|
|
|
|
| |
| NQR_FILE_EXTENSIONS = [ |
| |
| ".npy", ".npz", ".mat", ".h5", ".hdf5", |
| |
| ".csv", ".tsv", ".json", ".parquet", ".xlsx", ".xls", |
| ".txt", ".dat", ".asc", |
| |
| ".bin", ".raw", ".iq", ".cf32", ".cf64", ".cs16", ".cu8", |
| |
| ".wav", |
| |
| ".sigmf", ".jdx", ".dx", ".fid", |
| ] |
|
|
|
|
| def process_file(filepath, threshold, format_override): |
| """Main processing function for Gradio UI. |
| |
| Args: |
| filepath: Uploaded file path (from gr.File). |
| threshold: Detection threshold slider value. |
| format_override: Format override dropdown value. |
| |
| Returns: |
| Tuple of (verdict_text, results_table, metadata_json, plot_or_none) |
| """ |
| if filepath is None: |
| return "⏳ Upload a file to begin detection.", None, "{}", None |
| |
| t0 = time.time() |
| |
| |
| fmt_override = None |
| if format_override and format_override != "Auto-detect": |
| fmt_override = format_override.lower().replace(" ", "_") |
| |
| try: |
| |
| signals, metadata = load_signals( |
| filepath, |
| format_override=fmt_override, |
| ) |
| except Exception as e: |
| return f"❌ **Parse Error:** {str(e)}", None, "{}", None |
| |
| t_parse = time.time() - t0 |
| |
| |
| t1 = time.time() |
| features = extract_features_batch(signals) |
| t_feat = time.time() - t1 |
| |
| |
| t2 = time.time() |
| detector = get_detector() |
| |
| import torch |
| feat_tensor = torch.from_numpy(features) |
| |
| x_seq = detector.encoder.encode(feat_tensor).to(detector.device) |
| |
| mean_p, std_p = detector.ensemble.predict(x_seq) |
| probs = mean_p.cpu().numpy() |
| stds = std_p.cpu().numpy() |
| t_pred = time.time() - t2 |
| |
| total_time = time.time() - t0 |
| |
| |
| detections = (probs >= threshold).astype(int) |
| n_total = len(signals) |
| n_detected = int(detections.sum()) |
| |
| |
| if n_detected > 0: |
| pct = n_detected / n_total * 100 |
| verdict = ( |
| f"## ⚠️ ¹⁴N COMPOUND DETECTED\n\n" |
| f"**{n_detected}/{n_total}** signals ({pct:.1f}%) contain nitrogen-based compound signatures.\n\n" |
| f"Mean confidence: **{probs[detections == 1].mean():.1%}** | " |
| f"Mean uncertainty: **{stds[detections == 1].mean():.4f}**" |
| ) |
| else: |
| verdict = ( |
| f"## ✅ NO ¹⁴N COMPOUND DETECTED\n\n" |
| f"All **{n_total}** signals classified as noise-only.\n\n" |
| f"Mean probability: **{probs.mean():.4f}** | " |
| f"Mean uncertainty: **{stds.mean():.4f}**" |
| ) |
| |
| |
| results_df = pd.DataFrame({ |
| 'Signal #': range(1, n_total + 1), |
| 'Detection': ['⚠️ DETECTED' if d else '✅ Clear' for d in detections], |
| 'Probability': [f"{p:.4f}" for p in probs], |
| 'Uncertainty': [f"{s:.4f}" for s in stds], |
| }) |
| |
| |
| meta_display = { |
| "File": metadata.get('filename', 'unknown'), |
| "Format detected": metadata.get('format', 'unknown'), |
| "File size": f"{metadata.get('file_size_bytes', 0):,} bytes", |
| "Original samples": f"{metadata.get('original_samples', 0):,}", |
| "Signals extracted": n_total, |
| "Signal length": config.SIGNAL_LENGTH, |
| "Threshold": threshold, |
| "Parse time": f"{t_parse:.3f}s", |
| "Feature extraction": f"{t_feat:.3f}s", |
| "Prediction time": f"{t_pred:.3f}s", |
| "Total time": f"{total_time:.3f}s", |
| "Throughput": f"{n_total / max(total_time, 0.001):.1f} signals/sec", |
| "Model": f"CNN+SNN ensemble ({detector.ensemble_size} members)", |
| "Device": detector.device, |
| } |
| |
| |
| plot = generate_signal_plot(signals, probs, detections, metadata) |
| |
| return verdict, results_df, meta_display, plot |
|
|
|
|
| def generate_signal_plot(signals, probs, detections, metadata): |
| """Generate a matplotlib figure showing the first few signals.""" |
| try: |
| import matplotlib |
| matplotlib.use('Agg') |
| import matplotlib.pyplot as plt |
| |
| n_show = min(4, len(signals)) |
| fig, axes = plt.subplots(n_show, 2, figsize=(12, 3 * n_show)) |
| if n_show == 1: |
| axes = axes.reshape(1, 2) |
| |
| for i in range(n_show): |
| sig = signals[i] |
| |
| |
| ax = axes[i, 0] |
| ax.plot(np.abs(sig), linewidth=0.5, color='steelblue', alpha=0.8) |
| ax.set_ylabel(f'Signal {i+1}') |
| if i == 0: |
| ax.set_title('Magnitude (time domain)') |
| if i == n_show - 1: |
| ax.set_xlabel('Sample') |
| |
| det_str = '⚠️ DETECTED' if detections[i] else '✅ Clear' |
| ax.text(0.98, 0.95, f'{det_str} (p={probs[i]:.3f})', |
| transform=ax.transAxes, ha='right', va='top', |
| fontsize=9, fontweight='bold', |
| color='red' if detections[i] else 'green', |
| bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) |
| |
| |
| ax = axes[i, 1] |
| fft_mag = np.abs(np.fft.fft(sig))[:len(sig)//2] |
| ax.plot(fft_mag, linewidth=0.5, color='coral', alpha=0.8) |
| if i == 0: |
| ax.set_title('FFT Magnitude (frequency domain)') |
| if i == n_show - 1: |
| ax.set_xlabel('Frequency bin') |
| |
| fig.suptitle(f"NQR Signal Analysis — {metadata.get('filename', 'uploaded file')}", |
| fontsize=13, fontweight='bold') |
| plt.tight_layout() |
| return fig |
| except Exception: |
| return None |
|
|
|
|
| def create_demo(): |
| """Build the Gradio interface.""" |
| import gradio as gr |
| |
| with gr.Blocks( |
| title="NQR-SNN Detector — ¹⁴N Compound Detection", |
| theme=gr.themes.Soft(), |
| ) as demo: |
| |
| gr.Markdown(""" |
| # 🔬 NQR-SNN Detector v3.2 |
| ### Nuclear Quadrupole Resonance — ¹⁴N Compound Detection |
| |
| **Drag & drop any signal file** to detect nitrogen-based explosives/narcotics |
| (RDX, TNT, HMX, PETN, cocaine, heroin) from NQR receiver RF signals. |
| |
| Supports: **CSV, NPY, NPZ, MAT, HDF5, WAV, JSON, Parquet, Excel, |
| binary IQ (cf32/cs16/cu8), SigMF, JCAMP-DX, Bruker FID, plain text** |
| """) |
| |
| with gr.Row(): |
| with gr.Column(scale=2): |
| file_input = gr.File( |
| label="📡 Upload NQR/RF Signal File", |
| file_count="single", |
| file_types=NQR_FILE_EXTENSIONS, |
| type="filepath", |
| height=120, |
| ) |
| |
| with gr.Column(scale=1): |
| threshold_slider = gr.Slider( |
| minimum=0.1, maximum=0.9, value=0.5, step=0.05, |
| label="Detection Threshold", |
| info="Lower = more sensitive (more detections), Higher = more specific" |
| ) |
| |
| format_dropdown = gr.Dropdown( |
| choices=["Auto-detect"] + sorted([ |
| "CSV", "JSON", "NPY", "NPZ", "HDF5", "MATLAB", |
| "WAV", "Parquet", "Excel", "Text", |
| "Raw CF32", "Raw CS16", "Raw CU8", |
| "SigMF", "JCAMP", "Bruker FID", |
| ]), |
| value="Auto-detect", |
| label="Format Override", |
| info="Usually auto-detect works. Override if needed." |
| ) |
| |
| detect_btn = gr.Button("🔍 Detect", variant="primary", size="lg") |
| |
| |
| verdict_output = gr.Markdown( |
| value="⏳ Upload a file to begin detection.", |
| label="Verdict" |
| ) |
| |
| with gr.Row(): |
| with gr.Column(scale=2): |
| results_table = gr.DataFrame( |
| label="Signal-by-Signal Results", |
| interactive=False, |
| ) |
| with gr.Column(scale=1): |
| metadata_output = gr.JSON(label="Analysis Metadata") |
| |
| signal_plot = gr.Plot(label="Signal Visualization") |
| |
| |
| detect_btn.click( |
| fn=process_file, |
| inputs=[file_input, threshold_slider, format_dropdown], |
| outputs=[verdict_output, results_table, metadata_output, signal_plot], |
| ) |
| |
| file_input.upload( |
| fn=process_file, |
| inputs=[file_input, threshold_slider, format_dropdown], |
| outputs=[verdict_output, results_table, metadata_output, signal_plot], |
| ) |
| |
| |
| gr.Markdown(""" |
| --- |
| ### Supported Data Formats |
| |
| | Category | Formats | Notes | |
| |----------|---------|-------| |
| | **Scientific** | `.npy`, `.npz`, `.mat`, `.h5`/`.hdf5` | Auto-detects I/Q keys | |
| | **Tabular** | `.csv`, `.tsv`, `.json`, `.parquet`, `.xlsx` | Columns: `I`+`Q`, `real`+`imag`, or single array | |
| | **RF/SDR** | `.bin`, `.cf32`, `.cs16`, `.cu8`, `.iq` | Interleaved binary (GNU Radio, RTL-SDR, HackRF) | |
| | **Audio** | `.wav` | Stereo: L=I, R=Q | |
| | **NQR** | `.sigmf`, `.jdx`/`.dx`, `.fid` | SigMF archive, JCAMP-DX, Bruker FID | |
| | **Text** | `.txt`, `.dat`, `.asc` | Whitespace/comma-separated numbers | |
| |
| **Signal handling:** Files with >1024 samples are split into segments. |
| Files with <1024 samples are zero-padded. Multiple signals per file supported. |
| """) |
| |
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| import argparse |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--share", action="store_true") |
| parser.add_argument("--port", type=int, default=7860) |
| args = parser.parse_args() |
| |
| demo = create_demo() |
| demo.launch(server_port=args.port, share=args.share) |
|
|