ria-toolkit-oss/ria_toolkit_oss_cli/ria_toolkit_oss/view.py

419 lines
14 KiB
Python

"""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"]