From be295714685732020cdf2c260595cbf0bcd80f27 Mon Sep 17 00:00:00 2001 From: rakadu1 Date: Wed, 22 Apr 2026 10:08:11 +0530 Subject: [PATCH] Shenron: Centralized visualization engine into plots.py and implemented config-driven RD plot limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/ISOLATE/sim_radar_utils/__init__.py | 1 + scripts/ISOLATE/sim_radar_utils/config.yaml | 2 + scripts/ISOLATE/sim_radar_utils/plots.py | 152 ++++++++++++++++++++ scripts/test_shenron.py | 149 +------------------ 4 files changed, 156 insertions(+), 148 deletions(-) create mode 100644 scripts/ISOLATE/sim_radar_utils/__init__.py create mode 100644 scripts/ISOLATE/sim_radar_utils/plots.py diff --git a/scripts/ISOLATE/sim_radar_utils/__init__.py b/scripts/ISOLATE/sim_radar_utils/__init__.py new file mode 100644 index 0000000..1abed84 --- /dev/null +++ b/scripts/ISOLATE/sim_radar_utils/__init__.py @@ -0,0 +1 @@ +# Radar Utilities Package diff --git a/scripts/ISOLATE/sim_radar_utils/config.yaml b/scripts/ISOLATE/sim_radar_utils/config.yaml index 09a9bdf..d5bf4d4 100644 --- a/scripts/ISOLATE/sim_radar_utils/config.yaml +++ b/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: diff --git a/scripts/ISOLATE/sim_radar_utils/plots.py b/scripts/ISOLATE/sim_radar_utils/plots.py new file mode 100644 index 0000000..3215bba --- /dev/null +++ b/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 diff --git a/scripts/test_shenron.py b/scripts/test_shenron.py index 8f5d7db..bb35615 100644 --- a/scripts/test_shenron.py +++ b/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