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" xUnit: "m"
yLabel: "Velocity" yLabel: "Velocity"
yUnit: "m/s" yUnit: "m/s"
xRange: [-8, 8] # Doppler Velocity limits [m/s]
yRange: [0, 120] # Range limits [m]
winSize: [500, 400] winSize: [500, 400]
pos: [600, 50] pos: [600, 50]
radarPCD: 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 json
import base64 import base64
import argparse import argparse
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import io import io
from PIL import Image from PIL import Image
from mcap.writer import Writer 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