419 lines
14 KiB
Python
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"]
|