"""View command - Create visualizations from recordings.""" import os from pathlib import Path from typing import Optional import click from utils.io.recording import from_npy, load_recording from utils.view.view_signal import view_annotations, view_channels, view_sig from utils.view.view_signal_simple import view_simple_sig from .common import echo_progress, echo_verbose, load_yaml_config # Map visualization types to their functions and parameters VISUALIZATION_TYPES = { "simple": { "function": view_simple_sig, "description": "Simple time-domain and spectrogram view", "options": ["fast_mode", "compact_mode", "horizontal_mode", "constellation_mode", "labels_mode", "slice"], }, "full": { "function": view_sig, "description": "Full-featured plot with spectrogram, IQ, FFT, constellation, and metadata", "options": [ "plot_length", "plot_spectrogram", "iq", "frequency", "constellation", "metadata", "logo", "dark", "spines", ], }, "annotations": { "function": view_annotations, "description": "Annotation-focused spectrogram view", "options": ["channel", "dark"], }, "channels": {"function": view_channels, "description": "Multi-channel IQ and spectrogram view", "options": []}, } def parse_slice(slice_str: str) -> tuple: """Parse slice string in format 'start:end' or 'start:end:step'. Args: slice_str: Slice string (e.g., "1000:5000" or "::2") Returns: tuple: (start, end) or (start, end, step) Raises: click.BadParameter: If slice format is invalid """ try: parts = slice_str.split(":") if len(parts) == 2: start = int(parts[0]) if parts[0] else None end = int(parts[1]) if parts[1] else None return (start, end) elif len(parts) == 3: start = int(parts[0]) if parts[0] else None end = int(parts[1]) if parts[1] else None step = int(parts[2]) if parts[2] else None return (start, end, step) else: raise ValueError("Slice must have 2 or 3 parts") except (ValueError, IndexError): raise click.BadParameter( f"Invalid slice format: '{slice_str}'. " f"Expected formats: 'start:end' or 'start:end:step' (e.g., '1000:5000' or '::2')" ) def parse_figsize(figsize_str: str) -> tuple: """Parse figure size string in format 'WxH'. Args: figsize_str: Figure size string (e.g., "10x6") Returns: tuple: (width, height) in inches Raises: click.BadParameter: If format is invalid """ try: parts = figsize_str.lower().split("x") if len(parts) != 2: raise ValueError("Must have width and height") width = float(parts[0]) height = float(parts[1]) if width <= 0 or height <= 0: raise ValueError("Dimensions must be positive") return (width, height) except (ValueError, IndexError): raise click.BadParameter( f"Invalid figure size: '{figsize_str}'. " f"Expected format: 'WxH' (e.g., '10x6', '12.5x8')" ) def generate_output_path(input_path: str, output_path: Optional[str], format: str) -> str: """Generate output path if not specified. Args: input_path: Input file path output_path: User-specified output path (or None) format: Output format (png, pdf, svg, jpg) Returns: str: Full output path """ if output_path: return output_path # Auto-generate: input.sigmf -> input.png input_path = Path(input_path) # Handle SigMF files specially (remove -data/-meta suffixes) stem = input_path.stem if stem.endswith("-data") or stem.endswith("-meta"): stem = stem.rsplit("-", 1)[0] # Generate output filename output_filename = f"{stem}.{format}" return str(input_path.parent / output_filename) def load_recording_with_legacy(input_path: str, legacy: bool, verbose: bool): """Load recording, handling legacy NPY format. Args: input_path: Path to input file legacy: Whether to use legacy NPY loader verbose: Verbose output Returns: Recording object Raises: click.ClickException: If loading fails """ try: if legacy: echo_verbose(f"Loading as legacy NPY format: {input_path}", verbose) recording = from_npy(input_path, legacy=True) else: echo_verbose(f"Loading recording: {input_path}", verbose) recording = load_recording(input_path) return recording except FileNotFoundError: raise click.ClickException(f"Input file not found: {input_path}") except Exception as e: raise click.ClickException(f"Error loading recording: {e}") def get_view_output_path(should_save, overwrite, input, output, output_format): if should_save: output_path = generate_output_path(input, output, output_format) # Check if output exists if os.path.exists(output_path) and not overwrite: raise click.ClickException(f"Output file '{output_path}' already exists. " f"Use --overwrite to replace.") else: output_path = None return output_path def print_metadata(recording, quiet): # Print metadata to console if not quiet: click.echo("\nRecording Metadata:") click.echo("-" * 40) if recording._metadata: for key, value in sorted(recording._metadata.items()): # Format large numbers nicely if isinstance(value, (int, float)) and abs(value) >= 1000: if isinstance(value, float) and value >= 1e6: click.echo(f" {key}: {value:,.0f}") elif isinstance(value, float): click.echo(f" {key}: {value:,.2f}") else: click.echo(f" {key}: {value:,}") else: click.echo(f" {key}: {value}") else: click.echo(" (no metadata)") click.echo("-" * 40) click.echo() @click.command() @click.argument("input", type=click.Path(exists=True)) @click.option( "--type", "viz_type", type=click.Choice(list(VISUALIZATION_TYPES.keys())), default="simple", show_default=True, help="Visualization type", ) @click.option("--output", type=click.Path(), help="Output file path (default: auto-generated)") @click.option( "--format", "output_format", type=click.Choice(["png", "pdf", "svg", "jpg"]), default="png", show_default=True, help="Output format", ) @click.option("--show", is_flag=True, help="Display interactive plot") @click.option("--no-save", is_flag=True, help="Don't save file (only with --show)") @click.option("--dpi", type=int, default=300, show_default=True, help="Output DPI (PNG only)") @click.option("--figsize", type=str, help="Figure size in inches (e.g., '10x6')") @click.option("--title", type=str, help="Custom plot title") @click.option("--legacy", is_flag=True, help="Load input as legacy NPY format") @click.option("--config", type=click.Path(exists=True), help="YAML config file") # Type-specific options for 'simple' mode @click.option("--fast", is_flag=True, help="[simple] Fast mode - reduced quality for speed") @click.option("--compact", is_flag=True, help="[simple] Compact mode - minimal labels") @click.option("--horizontal", is_flag=True, help="[simple] Horizontal layout") @click.option("--constellation", is_flag=True, help="[simple] Show constellation plot") @click.option("--labels", is_flag=True, help="[simple] Show detailed labels") @click.option("--slice", type=str, help="[simple] Slice of signal (e.g., '1000:5000')") # Type-specific options for 'full' mode @click.option("--plot-length", type=int, help="[full] Number of samples to plot") @click.option("--no-spectrogram", is_flag=True, help="[full] Disable spectrogram") @click.option("--no-iq", is_flag=True, help="[full] Disable IQ plot") @click.option("--no-frequency", is_flag=True, help="[full] Disable frequency plot") @click.option("--no-constellation", is_flag=True, help="[full] Disable constellation") @click.option("--no-metadata", is_flag=True, help="[full] Disable metadata display") @click.option("--no-logo", is_flag=True, help="[full] Disable logo") @click.option("--light", is_flag=True, help="[full/annotations] Use light theme") @click.option("--spines", is_flag=True, help="[full] Show plot spines (borders)") # Type-specific options for 'annotations' mode @click.option("--channel", type=int, default=0, show_default=True, help="[annotations/channels] Channel to visualize") # Common options @click.option("--verbose", "-v", is_flag=True, help="Verbose output") @click.option("--quiet", "-q", is_flag=True, help="Suppress output") @click.option("--overwrite", is_flag=True, help="Overwrite existing output file") def view( input, viz_type, output, output_format, show, no_save, dpi, figsize, title, legacy, config, fast, compact, horizontal, constellation, labels, slice, plot_length, no_spectrogram, no_iq, no_frequency, no_constellation, no_metadata, no_logo, light, spines, channel, verbose, quiet, overwrite, ): """Create visualizations from recordings. INPUT is the recording file (SigMF, NPY, WAV, or MIDAS Blue format). \b Examples: # Basic visualization (saves to recording.png) utils view recording.sigmf \b # Spectrogram with custom output utils view capture.npy --output spec.png \b # Interactive display utils view signal.npy --show --no-save \b # High-resolution PDF utils view recording.blue --format pdf --dpi 600 \b # Simple mode with constellation utils view qam.wav --type simple --constellation --labels \b # Full-featured plot utils view capture.sigmf --type full --title "Lab Test" \b # Legacy NPY file utils view old_capture.npy --legacy --type simple """ # Load config file if specified if config: _ = load_yaml_config(config) # Config file overrides can be implemented here echo_verbose(f"Loaded config from: {config}", verbose) # Determine if we should save should_save = not no_save # Generate output path if needed output_path = get_view_output_path(should_save, overwrite, input, output, output_format) # Load recording echo_progress(f"Loading recording: {input}", quiet) recording = load_recording_with_legacy(input, legacy, verbose) num_samples = len(recording.data[0]) if len(recording.data.shape) > 1 else len(recording.data) echo_verbose(f"Loaded {num_samples:,} samples", verbose) # Print metadata to console print_metadata(recording, quiet) # Get visualization info viz_info = VISUALIZATION_TYPES[viz_type] # Type-specific parameters # Note: view_simple_sig has 'saveplot' param, others don't if viz_type == "simple": params = { "recording": recording, "output_path": output_path or "temp.png", "saveplot": should_save, "fast_mode": fast, "compact_mode": compact, "horizontal_mode": horizontal, "constellation_mode": constellation, "labels_mode": labels, } if slice: parsed_slice = parse_slice(slice) params["slice"] = parsed_slice echo_verbose(f"Using slice: {parsed_slice}", verbose) elif viz_type == "full": params = { "recording": recording, "output_path": output_path or "temp.png", "dpi": dpi, "plot_spectrogram": not no_spectrogram, "iq": not no_iq, "frequency": not no_frequency, "constellation": not no_constellation, "metadata": not no_metadata, "logo": not no_logo, "dark": not light, "spines": spines, } if plot_length: params["plot_length"] = plot_length echo_verbose(f"Plot length: {plot_length:,} samples", verbose) elif viz_type == "annotations": params = { "recording": recording, "output_path": output_path or "temp.png", "channel": channel, "dpi": dpi, "dark": not light, } elif viz_type == "channels": params = { "recording": recording, "output_path": output_path or "temp.png", } else: raise click.ClickException(f"Unknown visualization type: {viz_type}") if not should_save and not show and viz_type != "simple": raise click.ClickException(f"--no-save is not supported with --type {viz_type} (always saves)") if title: params["title"] = title # Generate visualization viz_func = viz_info["function"] echo_progress(f"Generating {viz_type} visualization...", quiet) echo_verbose(f"Using function: {viz_func.__name__}", verbose) try: _ = viz_func(**params) if should_save: echo_progress(f"Saved: {output_path}", quiet) # Show file size if verbose and os.path.exists(output_path): size_kb = os.path.getsize(output_path) / 1024 echo_verbose(f"File size: {size_kb:.1f} KB", verbose) # Show plot if requested if show: import matplotlib.pyplot as plt echo_verbose("Displaying plot...", verbose) plt.show() except Exception as e: raise click.ClickException(f"Error generating visualization: {e}") # For CLI registration __all__ = ["view"]