Fix 5 GUI bugs: threaded connect, button toggle, live CFAR/MTI/DC replay, stable heatmap, physical axis labels

- Bug 1: Move conn.open() to background thread to prevent GUI hang
- Bug 2: Save btn_connect as instance var, toggle Connect/Disconnect text
- Bug 3: Split opcodes into hardware-only (silent) and replay-adjustable
  (CFAR/MTI/DC-notch params trigger bit-accurate pipeline re-processing)
- Bug 4: EMA-smoothed vmax (alpha=0.15), fftshift on Doppler axis
- Bug 5: Physical axis labels (range in meters, velocity in m/s)
- Add _replay_mti(), _replay_dc_notch(), _replay_cfar() standalone functions
- Expand TestReplayConnection from 6 to 11 tests (42/42 pass)
This commit is contained in:
Jason
2026-03-20 19:36:21 +02:00
parent f8d80cc96e
commit eb907de3d1
3 changed files with 499 additions and 75 deletions
+90 -18
View File
@@ -27,6 +27,7 @@ import time
import queue
import logging
import argparse
import threading
from typing import Optional, Dict
from collections import deque
@@ -77,6 +78,16 @@ class RadarDashboard:
UPDATE_INTERVAL_MS = 100 # 10 Hz display refresh
# Radar parameters for physical axis labels (ADI CN0566 defaults)
# Config: [sample_rate=4e6, IF=1e5, RF=9.9e9, chirps=256, BW=500e6,
# ramp_time=300e-6, ...]
SAMPLE_RATE = 4e6 # Hz — ADC sample rate (baseband)
BANDWIDTH = 500e6 # Hz — chirp bandwidth
RAMP_TIME = 300e-6 # s — chirp ramp time
CENTER_FREQ = 10.5e9 # Hz — X-band center frequency
NUM_CHIRPS_FRAME = 32 # chirps per Doppler frame
C = 3e8 # m/s — speed of light
def __init__(self, root: tk.Tk, connection: FT601Connection,
recorder: DataRecorder):
self.root = root
@@ -101,6 +112,10 @@ class RadarDashboard:
self._fps_ts = time.time()
self._fps = 0.0
# Stable colorscale — exponential moving average of vmax
self._vmax_ema = 1000.0
self._vmax_alpha = 0.15 # smoothing factor (lower = more stable)
self._build_ui()
self._schedule_update()
@@ -138,9 +153,10 @@ class RadarDashboard:
self.lbl_frame = ttk.Label(top, text="Frame: 0", font=("Menlo", 10))
self.lbl_frame.pack(side="left", padx=16)
btn_connect = ttk.Button(top, text="Connect", command=self._on_connect,
style="Accent.TButton")
btn_connect.pack(side="right", padx=4)
self.btn_connect = ttk.Button(top, text="Connect",
command=self._on_connect,
style="Accent.TButton")
self.btn_connect.pack(side="right", padx=4)
self.btn_record = ttk.Button(top, text="Record", command=self._on_record)
self.btn_record.pack(side="right", padx=4)
@@ -161,9 +177,30 @@ class RadarDashboard:
self._build_log_tab(tab_log)
def _build_display_tab(self, parent):
# Compute physical axis limits
# Range resolution: dR = c / (2 * BW) per range bin
# But we decimate 1024→64 bins, so each bin spans 16 FFT bins.
# Range per FFT bin = c / (2 * BW) * (Fs / FFT_SIZE) — simplified:
# max_range = c * Fs / (4 * BW) for Fs-sampled baseband
# range_per_bin = max_range / NUM_RANGE_BINS
range_res = self.C / (2.0 * self.BANDWIDTH) # ~0.3 m per FFT bin
# After decimation 1024→64, each range bin = 16 FFT bins
range_per_bin = range_res * 16
max_range = range_per_bin * NUM_RANGE_BINS
# Velocity resolution: dv = lambda / (2 * N_chirps * T_chirp)
wavelength = self.C / self.CENTER_FREQ
# Max unambiguous velocity = lambda / (4 * T_chirp)
max_vel = wavelength / (4.0 * self.RAMP_TIME)
vel_per_bin = 2.0 * max_vel / NUM_DOPPLER_BINS
# Doppler axis: bin 0 = 0 Hz (DC), wraps at Nyquist
# For display: center DC, so shift axis to [-max_vel, +max_vel)
vel_lo = -max_vel
vel_hi = max_vel
# Matplotlib figure with 3 subplots
self.fig = Figure(figsize=(14, 7), facecolor=BG)
self.fig.subplots_adjust(left=0.06, right=0.98, top=0.94, bottom=0.08,
self.fig.subplots_adjust(left=0.07, right=0.98, top=0.94, bottom=0.10,
wspace=0.30, hspace=0.35)
# Range-Doppler heatmap
@@ -172,14 +209,21 @@ class RadarDashboard:
self._rd_img = self.ax_rd.imshow(
np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS)),
aspect="auto", cmap="inferno", origin="lower",
extent=[0, NUM_DOPPLER_BINS, 0, NUM_RANGE_BINS],
extent=[vel_lo, vel_hi, 0, max_range],
vmin=0, vmax=1000,
)
self.ax_rd.set_title("Range-Doppler Map", color=FG, fontsize=12)
self.ax_rd.set_xlabel("Doppler Bin", color=FG)
self.ax_rd.set_ylabel("Range Bin", color=FG)
self.ax_rd.set_xlabel("Velocity (m/s)", color=FG)
self.ax_rd.set_ylabel("Range (m)", color=FG)
self.ax_rd.tick_params(colors=FG)
# Save axis limits for coordinate conversions
self._vel_lo = vel_lo
self._vel_hi = vel_hi
self._max_range = max_range
self._range_per_bin = range_per_bin
self._vel_per_bin = vel_per_bin
# CFAR detection overlay (scatter)
self._det_scatter = self.ax_rd.scatter([], [], s=30, c=GREEN,
marker="x", linewidths=1.5,
@@ -191,11 +235,11 @@ class RadarDashboard:
wf_init = np.zeros((WATERFALL_DEPTH, NUM_RANGE_BINS))
self._wf_img = self.ax_wf.imshow(
wf_init, aspect="auto", cmap="viridis", origin="lower",
extent=[0, NUM_RANGE_BINS, 0, WATERFALL_DEPTH],
extent=[0, max_range, 0, WATERFALL_DEPTH],
vmin=0, vmax=5000,
)
self.ax_wf.set_title("Range Waterfall", color=FG, fontsize=12)
self.ax_wf.set_xlabel("Range Bin", color=FG)
self.ax_wf.set_xlabel("Range (m)", color=FG)
self.ax_wf.set_ylabel("Frame", color=FG)
self.ax_wf.tick_params(colors=FG)
@@ -300,17 +344,35 @@ class RadarDashboard:
self._acq_thread = None
self.conn.close()
self.lbl_status.config(text="DISCONNECTED", foreground=RED)
self.btn_connect.config(text="Connect")
log.info("Disconnected")
return
if self.conn.open():
# Open connection in a background thread to avoid blocking the GUI
self.lbl_status.config(text="CONNECTING...", foreground=YELLOW)
self.btn_connect.config(state="disabled")
self.root.update_idletasks()
def _do_connect():
ok = self.conn.open()
# Schedule UI update back on the main thread
self.root.after(0, lambda: self._on_connect_done(ok))
threading.Thread(target=_do_connect, daemon=True).start()
def _on_connect_done(self, success: bool):
"""Called on main thread after connection attempt completes."""
self.btn_connect.config(state="normal")
if success:
self.lbl_status.config(text="CONNECTED", foreground=GREEN)
self.btn_connect.config(text="Disconnect")
self._acq_thread = RadarAcquisition(
self.conn, self.frame_queue, self.recorder)
self._acq_thread.start()
log.info("Connected and acquisition started")
else:
self.lbl_status.config(text="CONNECT FAILED", foreground=RED)
self.btn_connect.config(text="Connect")
def _on_record(self):
if self.recorder.recording:
@@ -375,16 +437,26 @@ class RadarDashboard:
self.lbl_frame.config(text=f"Frame: {frame.frame_number}")
# Update range-Doppler heatmap
mag = frame.magnitude
vmax = max(np.max(mag), 1.0)
self._rd_img.set_data(mag)
self._rd_img.set_clim(vmin=0, vmax=vmax)
# FFT-shift Doppler axis so DC (bin 0) is in the center
mag = np.fft.fftshift(frame.magnitude, axes=1)
det_shifted = np.fft.fftshift(frame.detections, axes=1)
# Update CFAR overlay
det_coords = np.argwhere(frame.detections > 0)
# Stable colorscale via EMA smoothing of vmax
frame_vmax = float(np.max(mag)) if np.max(mag) > 0 else 1.0
self._vmax_ema = (self._vmax_alpha * frame_vmax +
(1.0 - self._vmax_alpha) * self._vmax_ema)
stable_vmax = max(self._vmax_ema, 1.0)
self._rd_img.set_data(mag)
self._rd_img.set_clim(vmin=0, vmax=stable_vmax)
# Update CFAR overlay — convert bin indices to physical coordinates
det_coords = np.argwhere(det_shifted > 0)
if len(det_coords) > 0:
offsets = np.column_stack([det_coords[:, 1] + 0.5,
det_coords[:, 0] + 0.5])
# det_coords[:, 0] = range bin, det_coords[:, 1] = Doppler bin
range_m = (det_coords[:, 0] + 0.5) * self._range_per_bin
vel_ms = self._vel_lo + (det_coords[:, 1] + 0.5) * self._vel_per_bin
offsets = np.column_stack([vel_ms, range_m])
self._det_scatter.set_offsets(offsets)
else:
self._det_scatter.set_offsets(np.empty((0, 2)))