KD099's picture
v3.2: app.py uses detector's trained encoder instead of DeterministicEncoder"
9ee31f9 verified
#!/usr/bin/env python3
"""
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
# -- Global detector (loaded once) --
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
# -- Supported file extensions for Gradio file picker --
NQR_FILE_EXTENSIONS = [
# Scientific
".npy", ".npz", ".mat", ".h5", ".hdf5",
# Tabular
".csv", ".tsv", ".json", ".parquet", ".xlsx", ".xls",
".txt", ".dat", ".asc",
# RF/SDR
".bin", ".raw", ".iq", ".cf32", ".cf64", ".cs16", ".cu8",
# Audio
".wav",
# NQR-specific
".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()
# Parse format override
fmt_override = None
if format_override and format_override != "Auto-detect":
fmt_override = format_override.lower().replace(" ", "_")
try:
# Load signals
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
# Feature extraction
t1 = time.time()
features = extract_features_batch(signals)
t_feat = time.time() - t1
# Run ensemble via detector (uses trained encoder)
t2 = time.time()
detector = get_detector()
import torch
feat_tensor = torch.from_numpy(features)
# v3.2: Use the detector's encoder (LearnableTemporalEncoder with trained weights)
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
# Build results
detections = (probs >= threshold).astype(int)
n_total = len(signals)
n_detected = int(detections.sum())
# Verdict
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 table
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],
})
# Metadata
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,
}
# Generate signal visualization
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]
# Time domain
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))
# Frequency domain
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")
# Results
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")
# Wire up
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],
)
# Examples
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)