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:
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user