Browse Source

Shenron: Centralized visualization engine into plots.py and implemented config-driven RD plot limits

- Refactored heatmap rendering, stateful engine, and scan conversion from test_shenron.py into new sim_radar_utils.plots module
- Updated FastHeatmapEngine to automatically respect xRange/yRange overrides sourced from config.yaml
- Calibrated Range-Doppler plot limits to ±8 m/s in config.yaml for optimized visual inspection
- Cleaned up test_shenron.py by removing ~150 lines of redundant visualization logic and heavy Matplotlib imports
- Added __init__.py to sim_radar_utils to ensure proper package identification
main
RUSHIL AMBARISH KADU 4 weeks ago
parent
commit
be29571468
  1. 1
      scripts/ISOLATE/sim_radar_utils/__init__.py
  2. 2
      scripts/ISOLATE/sim_radar_utils/config.yaml
  3. 152
      scripts/ISOLATE/sim_radar_utils/plots.py
  4. 149
      scripts/test_shenron.py

1
scripts/ISOLATE/sim_radar_utils/__init__.py

@ -0,0 +1 @@
# Radar Utilities Package

2
scripts/ISOLATE/sim_radar_utils/config.yaml

@ -69,6 +69,8 @@ Visualize:
xUnit: "m"
yLabel: "Velocity"
yUnit: "m/s"
xRange: [-8, 8] # Doppler Velocity limits [m/s]
yRange: [0, 120] # Range limits [m]
winSize: [500, 400]
pos: [600, 50]
radarPCD:

152
scripts/ISOLATE/sim_radar_utils/plots.py

@ -0,0 +1,152 @@
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import io
import base64
from PIL import Image
from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg
import os
import yaml
# --- Config Loading ---
try:
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.yaml')
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
viz_cfg = config.get('Visualize', {})
except Exception as e:
print(f"[WARNING] Could not load config.yaml in plots.py: {e}")
viz_cfg = {}
def render_heatmap(data, vmin=None, vmax=None, cmap='viridis'):
"""Converts a 2D numpy array to a colormapped PNG base64 string."""
if data is None or data.size == 0:
return None
# Normalize to 0-1
if vmin is None: vmin = np.min(data)
if vmax is None: vmax = np.max(data)
if vmax > vmin:
norm_data = (data - vmin) / (vmax - vmin)
else:
norm_data = np.zeros_like(data)
# Apply colormap
color_mapped = matplotlib.colormaps[cmap](norm_data) # [H, W, 4]
# Convert to 8-bit RGB
rgb = (color_mapped[:, :, :3] * 255).astype(np.uint8)
img = Image.fromarray(rgb)
buffered = io.BytesIO()
img.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode("ascii")
class FastHeatmapEngine:
"""Stateful Matplotlib engine that reuses figure memory to achieve high-speed frame rendering."""
def __init__(self, extent, cmap='jet', vmin=None, vmax=None, title='Heatmap', xlabel='X', ylabel='Y', xlim=None, ylim=None, aspect='auto', interpolation=None):
self.vmin = vmin
self.vmax = vmax
# Load config-based overrides
# We look for a key in Visualize that matches the plot type
plot_type = 'rangeDoppler' if 'Doppler' in title else ('rangeAoA' if 'Azimuth' in title else None)
if plot_type and plot_type in viz_cfg:
cfg = viz_cfg[plot_type]
if 'xRange' in cfg: xlim = cfg['xRange']
if 'yRange' in cfg: ylim = cfg['yRange']
self.fig = Figure(figsize=(6, 5), dpi=100)
self.canvas = FigureCanvasAgg(self.fig)
self.ax = self.fig.add_subplot(111)
cm_obj = matplotlib.colormaps.get_cmap(cmap).copy()
cm_obj.set_bad(color='white')
# Dummy matrix to initialize geometry
dummy = np.zeros((2, 2))
self.im = self.ax.imshow(dummy, extent=extent, cmap=cm_obj, vmin=vmin, vmax=vmax, origin='upper', aspect=aspect, interpolation=interpolation)
# Check for config overrides
# (This is where the user's requested RD limit overrides would go)
if xlim is not None: self.ax.set_xlim(xlim)
if ylim is not None: self.ax.set_ylim(ylim)
self.ax.set_xlabel(xlabel)
self.ax.set_ylabel(ylabel)
self.ax.set_title(title)
self.ax.grid(color='gray', linestyle='--', linewidth=0.5, alpha=0.7)
self.fig.colorbar(self.im, ax=self.ax, label='Magnitude (dB)')
self.fig.tight_layout()
def render(self, data):
self.im.set_data(data)
if self.vmin is None and self.vmax is None:
v_low, v_high = np.nanmin(data), np.nanmax(data)
self.im.set_clim(v_low if not np.isnan(v_low) else 0.0, v_high if not np.isnan(v_high) else 1.0)
self.fig.canvas.draw()
rgba = np.asarray(self.canvas.buffer_rgba())
img = Image.fromarray(rgba)
buf = io.BytesIO()
img.save(buf, format='png')
return base64.b64encode(buf.getvalue()).decode("ascii")
def postprocess_ra(ra_heatmap, range_axis, smooth_sigma=1.0):
"""
Refined RA post-processing pipeline for Physical Realism.
"""
# 1. Clutter removal (subtract per-range-bin mean to suppress static ground)
clutter = np.mean(ra_heatmap, axis=1, keepdims=True)
ra = ra_heatmap - (0.8 * clutter)
ra = np.clip(ra, 1e-9, None)
# 2. Physics-based dynamic range compression (Linear -> Log)
SYSTEM_GAIN_OFFSET = 68.0
ra_db = 10 * np.log10(ra) - SYSTEM_GAIN_OFFSET
# 3. Fixed dynamic range clipping (-5 to 45 dB)
ra_db = np.clip(ra_db, -5, 45)
# 4. Optional Gaussian smoothing
if smooth_sigma > 0:
from scipy.ndimage import gaussian_filter
ra_db = gaussian_filter(ra_db, sigma=smooth_sigma)
return ra_db
def scan_convert_ra(ra_heatmap, range_axis, angle_axis, img_size=512, max_display_range=None):
"""
Polar-to-Cartesian scan conversion.
Converts RA (Range, Angle) polar data into a Sector plot.
"""
true_max_range = range_axis[-1]
max_range = max_display_range if max_display_range is not None else true_max_range
theta_min = angle_axis[0]
theta_max = angle_axis[-1]
# Create Cartesian Grid
x = np.linspace(-max_range, max_range, img_size)
y = np.linspace(max_range, 0, img_size)
X, Y = np.meshgrid(x, y)
# Convert to Polar Coordinates
R_query = np.sqrt(X**2 + Y**2)
Theta_query = np.arctan2(X, Y)
# Mask Valid Radar FOV
fov_mask = (Theta_query >= theta_min) & (Theta_query <= theta_max) & (R_query <= max_range)
# Map RA Heatmap to Cartesian Grid
r_idx = np.clip(((R_query / true_max_range) * (ra_heatmap.shape[0] - 1)).astype(int), 0, ra_heatmap.shape[0] - 1)
theta_range = theta_max - theta_min
theta_idx = np.clip(((Theta_query - theta_min) / theta_range * (ra_heatmap.shape[1] - 1)).astype(int), 0, ra_heatmap.shape[1] - 1)
# Project
cartesian = np.full((img_size, img_size), np.nan, dtype=np.float64)
cartesian[fov_mask] = ra_heatmap[r_idx[fov_mask], theta_idx[fov_mask]]
return cartesian

149
scripts/test_shenron.py

@ -6,9 +6,6 @@ from pathlib import Path
import json
import base64
import argparse
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import io
from PIL import Image
from mcap.writer import Writer
@ -134,151 +131,7 @@ FOXGLOVE_METRICS_SCHEMA = {
}
}
def render_heatmap(data, vmin=None, vmax=None, cmap='viridis'):
"""Converts a 2D numpy array to a colormapped PNG base64 string."""
if data is None or data.size == 0:
return None
# Simple log scaling if needed? For now we assume input is power or magnitude
# Normalize to 0-1
if vmin is None: vmin = np.min(data)
if vmax is None: vmax = np.max(data)
if vmax > vmin:
norm_data = (data - vmin) / (vmax - vmin)
else:
norm_data = np.zeros_like(data)
# Apply colormap (Updated to use modern matplotlib.colormaps API)
color_mapped = matplotlib.colormaps[cmap](norm_data) # [H, W, 4]
# Convert to 8-bit RGB
rgb = (color_mapped[:, :, :3] * 255).astype(np.uint8)
img = Image.fromarray(rgb)
buffered = io.BytesIO()
img.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode("ascii")
from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg
class FastHeatmapEngine:
"""Stateful Matplotlib engine that reuses figure memory to achieve high-speed frame rendering."""
def __init__(self, extent, cmap='jet', vmin=None, vmax=None, title='Range-Azimuth Heatmap', xlabel='Lateral distance [m]', ylabel='Longitudinal distance [m]', xlim=None, ylim=None, aspect='auto', interpolation=None):
self.vmin = vmin
self.vmax = vmax
self.fig = Figure(figsize=(6, 5), dpi=100)
self.canvas = FigureCanvasAgg(self.fig)
self.ax = self.fig.add_subplot(111)
cm_obj = matplotlib.colormaps.get_cmap(cmap).copy()
cm_obj.set_bad(color='white')
# Dummy matrix to initialize geometry
dummy = np.zeros((2, 2))
self.im = self.ax.imshow(dummy, extent=extent, cmap=cm_obj, vmin=vmin, vmax=vmax, origin='upper', aspect=aspect, interpolation=interpolation)
if xlim is not None: self.ax.set_xlim(xlim)
if ylim is not None: self.ax.set_ylim(ylim)
self.ax.set_xlabel(xlabel)
self.ax.set_ylabel(ylabel)
self.ax.set_title(title)
self.ax.grid(color='gray', linestyle='--', linewidth=0.5, alpha=0.7)
self.fig.colorbar(self.im, ax=self.ax, label='Magnitude (dB)')
self.fig.tight_layout()
def render(self, data):
self.im.set_data(data)
if self.vmin is None and self.vmax is None:
v_low, v_high = np.nanmin(data), np.nanmax(data)
self.im.set_clim(v_low if not np.isnan(v_low) else 0.0, v_high if not np.isnan(v_high) else 1.0)
self.fig.canvas.draw()
rgba = np.asarray(self.canvas.buffer_rgba())
img = Image.fromarray(rgba)
buf = io.BytesIO()
img.save(buf, format='png')
return base64.b64encode(buf.getvalue()).decode("ascii")
def postprocess_ra(ra_heatmap, range_axis, smooth_sigma=1.0):
"""
Refined RA post-processing pipeline for Physical Realism.
Restores the natural power decay (near objects are brighter) by removing
the misleading per-range normalization.
Args:
ra_heatmap : 2-D ndarray (N_range, N_angle), linear power
range_axis : 1-D ndarray, physical range in metres
smooth_sigma: float, Gaussian sigma (0 to disable)
Returns:
2-D ndarray (N_range, N_angle), processed linear or log units
"""
# 1. Clutter removal (subtract per-range-bin mean to suppress static ground)
# This preserves relative intensity between actual objects
clutter = np.mean(ra_heatmap, axis=1, keepdims=True)
ra = ra_heatmap - (0.8 * clutter) # Subtract 80% of mean to keep some context
ra = np.clip(ra, 1e-9, None)
# 2. Physics-based dynamic range compression (Linear -> Log)
# Conversion to dB scale with System Gain Calibration (calculated from Iter 28)
# This offset maps raw physical power to the diagnostic visual range.
SYSTEM_GAIN_OFFSET = 68.0
ra_db = 10 * np.log10(ra) - SYSTEM_GAIN_OFFSET
# 3. Fixed dynamic range clipping (-5 to 45 dB)
# This ensures consistent contrast and preserves physical R^-4 decay
ra_db = np.clip(ra_db, -5, 45)
# 4. Optional Gaussian smoothing to reduce speckle
if smooth_sigma > 0:
from scipy.ndimage import gaussian_filter
ra_db = gaussian_filter(ra_db, sigma=smooth_sigma)
return ra_db
def scan_convert_ra(ra_heatmap, range_axis, angle_axis, img_size=512, max_display_range=None):
"""
Polar-to-Cartesian scan conversion following FIG / Guide logic.
Converts RA (Range, Angle) polar data into a 120° Fan-shaped Sector plot.
"""
true_max_range = range_axis[-1]
max_range = max_display_range if max_display_range is not None else true_max_range
theta_min = angle_axis[0]
theta_max = angle_axis[-1]
# 4. Create Cartesian Grid (X: lateral, Y: forward)
# Origin (0,0) will be at bottom-center of the 512x512 image
x = np.linspace(-max_range, max_range, img_size)
y = np.linspace(max_range, 0, img_size) # Far to Near
X, Y = np.meshgrid(x, y)
# 4. Convert to Polar Coordinates
R_query = np.sqrt(X**2 + Y**2)
Theta_query = np.arctan2(X, Y)
# 5. Mask Valid Radar FOV (120-degree sector)
fov_mask = (Theta_query >= theta_min) & (Theta_query <= theta_max) & (R_query <= max_range)
# 5. Map RA Heatmap to Cartesian Grid
# Calculate fractional indices based on the true underlying data range
r_idx = np.clip(((R_query / true_max_range) * (ra_heatmap.shape[0] - 1)).astype(int), 0, ra_heatmap.shape[0] - 1)
# theta index: Shift by theta_min to align 0..120 range
theta_range = theta_max - theta_min
theta_idx = np.clip(((Theta_query - theta_min) / theta_range * (ra_heatmap.shape[1] - 1)).astype(int), 0, ra_heatmap.shape[1] - 1)
# Project
cartesian = np.full((img_size, img_size), np.nan, dtype=np.float64)
cartesian[fov_mask] = ra_heatmap[r_idx[fov_mask], theta_idx[fov_mask]]
return cartesian
from sim_radar_utils.plots import render_heatmap, FastHeatmapEngine, postprocess_ra, scan_convert_ra

Loading…
Cancel
Save