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

733 lines
27 KiB
Python

"""Transform command - Apply signal transformations to recordings."""
import importlib
import importlib.util
import inspect
import os
from pathlib import Path
import click
from utils.data.recording import Recording
from utils.io.recording import load_recording
from utils.transforms import iq_augmentations, iq_channel_models, iq_impairments
from utils_cli.utils.common import (
echo_progress,
echo_verbose,
format_sample_count,
save_recording,
)
def get_available_transforms(module):
"""Get list of public transform functions from a module.
Args:
module: Python module to inspect
Returns:
dict: {name: function} for all public callables
"""
transforms = {}
for name, obj in inspect.getmembers(module, inspect.isfunction):
if not name.startswith("_"):
transforms[name] = obj
return transforms
def get_transform_help(func):
"""Extract help info from a transform function.
Args:
func: Transform function to inspect
Returns:
dict: {description, params}
"""
sig = inspect.signature(func)
doc = inspect.getdoc(func) or ""
# Get first line of docstring as description
description = doc.split("\n")[0] if doc else "No description"
# Extract parameters from signature (skip 'signal')
params = {}
for param_name, param in sig.parameters.items():
if param_name == "signal":
continue
default = param.default
param_type = "optional" if default != inspect.Parameter.empty else "required"
default_str = f" (default: {default})" if default != inspect.Parameter.empty else ""
params[param_name] = {
"type": param_type,
"default": default,
"annotation": str(param.annotation) if param.annotation != inspect.Parameter.empty else "any",
"display": f"{param_name} ({param_type}){default_str}",
}
return {"description": description, "full_doc": doc, "params": params}
def show_transform_help(transform_name, func):
"""Display compact help for a specific transform."""
info = get_transform_help(func)
click.echo(f"\n{transform_name}")
click.echo("-" * 50)
click.echo(info["description"])
if info["params"]:
click.echo("\nParameters:")
for param_name, param_info in sorted(info["params"].items()):
click.echo(f" {param_name:20} {param_info['display']}")
click.echo()
def quick_view_transform(recording, output_path, title="Transform Result"):
"""Create a quick PNG visualization of transformed recording using constellation plot."""
try:
from utils.view.view_signal_simple import view_simple_sig
# Create PNG in same directory as output
output_dir = Path(output_path).parent
base_name = Path(output_path).stem
png_path = output_dir / f"{base_name}_preview.png"
# Use simple view with constellation
view_simple_sig(recording, output_path=str(png_path), constellation_mode=True, title=title, saveplot=True)
click.echo(f"Visualization saved to: {png_path}")
except Exception as e:
click.echo(f"Warning: Could not create visualization: {e}")
def generate_transform_suffix(transform_name, params):
"""Generate a short suffix for the output filename based on transform and params.
Args:
transform_name: Name of the transform
params: Dict of parameters
Returns:
str: A short suffix like "awgn15" or "freqoffset10k"
"""
suffix = transform_name.replace("_", "")
# Add key parameter values
if "snr_db" in params:
suffix += f"{int(params['snr_db'])}"
elif "snr" in params:
suffix += f"{int(params['snr'])}"
elif "amplitude_variance" in params:
suffix += f"{int(params['amplitude_variance']*100)}av"
elif "phase_variance" in params:
suffix += f"{int(params['phase_variance']*100000)}pv"
elif "compression_gain" in params:
suffix += f"{params['compression_gain']:.2f}".rstrip("0").rstrip(".")
elif "offset_hz" in params:
hz = params["offset_hz"]
if abs(hz) >= 1e6:
suffix += f"{hz/1e6:.0f}m"
elif abs(hz) >= 1e3:
suffix += f"{hz/1e3:.0f}k"
else:
suffix += f"{hz:.0f}"
elif "offset" in params:
suffix += f"{params['offset']:.2f}".rstrip("0").rstrip(".")
elif "doppler_hz" in params:
suffix += f"{params['doppler_hz']:.0f}"
return suffix
def parse_transform_params(param_strings):
"""Parse transform parameters from CLI options.
Args:
param_strings: List of 'KEY=VALUE' strings
Returns:
dict: {key: value} with types inferred
"""
params = {}
if not param_strings:
return params
for param_str in param_strings:
if "=" not in param_str:
raise click.BadParameter(f"Parameter must be KEY=VALUE, got: {param_str}")
key, value = param_str.split("=", 1)
key = key.strip()
value = value.strip()
# Try to infer type
try:
# Try to parse scientific notation and floats
if "e" in value.lower() or "." in value:
params[key] = float(value)
else:
params[key] = int(value)
except ValueError:
# Keep as string
params[key] = value
return params
def load_custom_transforms(transform_dir):
"""Load custom transform functions from a directory.
Args:
transform_dir: Path to directory containing .py files with transform functions
Returns:
dict: {transform_name: function} for all public functions in all .py files
Raises:
click.ClickException: If directory doesn't exist or no transforms found
"""
transform_dir = Path(transform_dir)
if not transform_dir.exists():
raise click.ClickException(f"Transform directory does not exist: {transform_dir}")
if not transform_dir.is_dir():
raise click.ClickException(f"Path is not a directory: {transform_dir}")
transforms = {}
py_files = list(transform_dir.glob("*.py"))
if not py_files:
raise click.ClickException(f"No .py files found in {transform_dir}")
for py_file in py_files:
try:
# Load module dynamically
spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
if spec is None or spec.loader is None:
click.echo(f"Warning: Could not load {py_file.name}")
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Extract all public functions
for name, obj in inspect.getmembers(module, inspect.isfunction):
if not name.startswith("_"):
# Store with source file info for metadata
obj._transform_source_file = py_file.name
transforms[name] = obj
except Exception as e:
raise click.ClickException(f"Failed to load {py_file.name}: {e}")
return transforms
def check_input_errors(item_name: str, item, available, input, help_transform):
if item is None:
if help_transform:
raise click.UsageError(f"{item_name.upper()} must be specified for --help-transform")
else:
raise click.UsageError(f"{item_name.upper()} must be specified (or use --list)")
if item not in available:
raise click.ClickException(f"Unknown {item_name}: {item}\n" f"Use --list to see available options")
if input is None and not help_transform:
raise click.UsageError("INPUT must be specified")
def load_input(input, verbose):
# Load input
try:
recording = load_recording(input)
except Exception as e:
raise click.ClickException(f"Failed to load input: {e}")
echo_verbose(f"Loaded {format_sample_count(recording.data.shape[-1])} samples", verbose)
return recording
@click.group()
def transform():
"""Apply signal transformations to recordings.
Transform supports three categories of operations:
- augment: Modify signal to create new ML examples
- impair: Degrade signal with noise, distortion, etc.
- apply_channel: Apply channel models (fading, Doppler, etc.)
Each operation is applied independently. Chain multiple transforms by
running this command multiple times.
\b
Examples:
# List available augmentations
utils transform augment --list
\b
# Apply channel swap
utils transform augment channel_swap input.npy
\b
# Apply AWGN impairment
utils transform impair awgn input.npy --snr-db 15
\b
# Apply Rayleigh fading channel
utils transform apply_channel rayleigh input.npy --num-paths 5
"""
pass
@transform.command(name="augment")
@click.argument("augmentation", required=False)
@click.argument("input", type=click.Path(exists=True), required=False)
@click.argument("output", type=click.Path(), required=False)
@click.option("--list", "list_transforms", is_flag=True, help="List available augmentations")
@click.option("--help-transform", is_flag=True, help="Show parameters for this augmentation")
@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)")
@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot")
@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--quiet", "-q", is_flag=True, help="Suppress output")
def augment(augmentation, input, output, list_transforms, help_transform, params, view, overwrite, verbose, quiet):
"""Apply augmentation transforms to recordings.
Augmentations modify signals to create new training examples without
degrading quality (e.g., channel swap, time reversal, quantization).
Examples:
# List all augmentations
\b
utils transform augment --list
# Show parameters for an augmentation
\b
utils transform augment channel_swap --help-transform
# Apply augmentation
\b
utils transform augment channel_swap input.npy
# Apply with parameters and save visualization
\b
utils transform augment drop_samples input.npy --params max_section_size=5 --view
"""
available = get_available_transforms(iq_augmentations)
if list_transforms:
click.echo("Available augmentations:")
for name in sorted(available.keys()):
func = available[name]
docstring = (func.__doc__ or "").split("\n")[0].strip()
click.echo(f" {name:30} {docstring}")
return
if help_transform:
check_input_errors("augmentation", augmentation, available, input, help_transform)
show_transform_help(augmentation, available[augmentation])
return
check_input_errors("augmentation", augmentation, available, input, help_transform)
# Generate output filename if not provided
if output is None:
input_path = Path(input)
input_stem = input_path.stem
ext = input_path.suffix
suffix = generate_transform_suffix(augmentation, parse_transform_params(params))
output = str(input_path.parent / f"{input_stem}_{suffix}{ext}")
echo_verbose(f"Auto-generated output: {output}", verbose)
# Check if output exists
if not overwrite and Path(output).exists():
raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace")
echo_progress(f"Augmenting: {os.path.basename(input)}{os.path.basename(output)}", quiet)
echo_verbose(f"Transform: {augmentation}", verbose)
# Load input
recording = load_input(input, verbose)
# Parse and apply transform
try:
transform_func = available[augmentation]
transform_params = parse_transform_params(params)
echo_verbose(f"Parameters: {transform_params}", verbose)
result = transform_func(recording, **transform_params)
except Exception as e:
raise click.ClickException(f"Transform failed: {e}")
# Track transform in metadata (Recording.metadata is a property that returns a copy)
# So we need to work with a copy and create a new Recording with updated metadata
updated_metadata = result.metadata.copy()
if "transforms_applied" not in updated_metadata:
updated_metadata["transforms_applied"] = []
updated_metadata["transforms_applied"].append(
{"type": "augment", "name": augmentation, "params": parse_transform_params(params)}
)
# Create new recording with updated metadata
result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations)
# Save output
try:
save_recording(result, output, overwrite=overwrite, verbose=verbose)
echo_progress(f"Saved to: {output}", quiet)
except Exception as e:
raise click.ClickException(f"Failed to save output: {e}")
# Optional: Create visualization
if view:
echo_verbose("Creating visualization...", verbose)
quick_view_transform(result, output, title=f"{augmentation.replace('_', ' ').title()} - {Path(output).name}")
@transform.command(name="impair")
@click.argument("impairment", required=False)
@click.argument("input", type=click.Path(exists=True), required=False)
@click.argument("output", type=click.Path(), required=False)
@click.option("--list", "list_transforms", is_flag=True, help="List available impairments")
@click.option("--help-transform", is_flag=True, help="Show parameters for this impairment")
@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)")
@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot")
@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--quiet", "-q", is_flag=True, help="Suppress output")
def impair(impairment, input, output, list_transforms, help_transform, params, view, overwrite, verbose, quiet):
"""Apply impairment transforms to recordings.
Impairments degrade signals by adding noise, distortion, and other
channel effects (e.g., AWGN, phase noise, IQ imbalance).
Examples:
# List all impairments
\b
utils transform impair --list
# Show parameters for an impairment
\b
utils transform impair add_awgn_to_signal --help-transform
# Apply impairment
\b
utils transform impair add_awgn_to_signal input.npy --params snr=10
# Apply with visualization
\b
utils transform impair add_phase_noise input.npy --params phase_variance=0.001 --view
"""
available = get_available_transforms(iq_impairments)
if list_transforms:
click.echo("Available impairments:")
for name in sorted(available.keys()):
func = available[name]
docstring = (func.__doc__ or "").split("\n")[0].strip()
click.echo(f" {name:30} {docstring}")
return
if help_transform:
check_input_errors("impairment", impairment, available, input, help_transform)
show_transform_help(impairment, available[impairment])
return
check_input_errors("impairment", impairment, available, input, help_transform)
# Generate output filename if not provided
if output is None:
input_path = Path(input)
input_stem = input_path.stem
ext = input_path.suffix
suffix = generate_transform_suffix(impairment, parse_transform_params(params))
output = str(input_path.parent / f"{input_stem}_{suffix}{ext}")
echo_verbose(f"Auto-generated output: {output}", verbose)
# Check if output exists
if not overwrite and Path(output).exists():
raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace")
echo_progress(f"Impairing: {os.path.basename(input)}{os.path.basename(output)}", quiet)
echo_verbose(f"Transform: {impairment}", verbose)
# Load input
recording = load_input(input, verbose)
# Parse and apply transform
try:
transform_func = available[impairment]
transform_params = parse_transform_params(params)
echo_verbose(f"Parameters: {transform_params}", verbose)
result = transform_func(recording, **transform_params)
except Exception as e:
raise click.ClickException(f"Transform failed: {e}")
# Track transform in metadata (Recording.metadata is a property that returns a copy)
updated_metadata = result.metadata.copy()
if "transforms_applied" not in updated_metadata:
updated_metadata["transforms_applied"] = []
updated_metadata["transforms_applied"].append(
{"type": "impair", "name": impairment, "params": parse_transform_params(params)}
)
# Create new recording with updated metadata
result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations)
# Save output
try:
save_recording(result, output, overwrite=overwrite, verbose=verbose)
echo_progress(f"Saved to: {output}", quiet)
except Exception as e:
raise click.ClickException(f"Failed to save output: {e}")
# Optional: Create visualization
if view:
echo_verbose("Creating visualization...", verbose)
quick_view_transform(result, output, title=f"{impairment.replace('_', ' ').title()} - {Path(output).name}")
@transform.command(name="apply_channel")
@click.argument("channel_model", required=False)
@click.argument("input", type=click.Path(exists=True), required=False)
@click.argument("output", type=click.Path(), required=False)
@click.option("--list", "list_transforms", is_flag=True, help="List available channel models")
@click.option("--help-transform", is_flag=True, help="Show parameters for this channel model")
@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)")
@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot")
@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--quiet", "-q", is_flag=True, help="Suppress output")
def apply_channel(
channel_model, input, output, list_transforms, help_transform, params, view, overwrite, verbose, quiet
):
"""Apply channel models to recordings.
Channel models simulate RF propagation effects like fading, Doppler shift,
and multipath reflections.
Use --list to see available channel models and their parameters.
\b
Examples:
utils transform apply_channel rayleigh_fading_channel input.npy --params num_paths=3 snr_db=15
\b
utils transform apply_channel doppler_channel recordings/input.npy \\
--params satellite_velocity=7500 \\
--params satellite_initial_distance=400000 \\
--params frequency=1e9 \\
--params sample_rate=2e6
"""
available = get_available_transforms(iq_channel_models)
if list_transforms:
click.echo("Available channel models:")
for name in sorted(available.keys()):
func = available[name]
docstring = (func.__doc__ or "").split("\n")[0].strip()
click.echo(f" {name:30} {docstring}")
return
if help_transform:
check_input_errors("channel_model", channel_model, available, input, help_transform)
show_transform_help(channel_model, available[channel_model])
return
check_input_errors("channel_model", channel_model, available, input, help_transform)
# Generate output filename if not provided
if output is None:
input_path = Path(input)
input_stem = input_path.stem
ext = input_path.suffix
suffix = generate_transform_suffix(channel_model, parse_transform_params(params))
output = str(input_path.parent / f"{input_stem}_{suffix}{ext}")
echo_verbose(f"Auto-generated output: {output}", verbose)
# Check if output exists
if not overwrite and Path(output).exists():
raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace")
echo_progress(f"Applying channel: {os.path.basename(input)}{os.path.basename(output)}", quiet)
echo_verbose(f"Channel model: {channel_model}", verbose)
# Load input
recording = load_input(input, verbose)
# Parse and apply transform
try:
transform_func = available[channel_model]
transform_params = parse_transform_params(params)
echo_verbose(f"Parameters: {transform_params}", verbose)
result = transform_func(recording, **transform_params)
except Exception as e:
raise click.ClickException(f"Transform failed: {e}")
# Track transform in metadata (Recording.metadata is a property that returns a copy)
updated_metadata = result.metadata.copy()
if "transforms_applied" not in updated_metadata:
updated_metadata["transforms_applied"] = []
updated_metadata["transforms_applied"].append(
{"type": "channel", "name": channel_model, "params": parse_transform_params(params)}
)
# Create new recording with updated metadata
result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations)
# Save output
try:
save_recording(result, output, overwrite=overwrite, verbose=verbose)
echo_progress(f"Saved to: {output}", quiet)
except Exception as e:
raise click.ClickException(f"Failed to save output: {e}")
# Optional: Create visualization
if view:
echo_verbose("Creating visualization...", verbose)
quick_view_transform(result, output, title=f"{channel_model.replace('_', ' ').title()} - {Path(output).name}")
@transform.command(name="custom")
@click.argument("transform_name", required=False)
@click.argument("input", type=click.Path(exists=True), required=False)
@click.argument("output", type=click.Path(), required=False)
@click.option(
"--transform-dir",
type=click.Path(exists=True),
required=True,
help="Path to directory containing custom transform .py files",
)
@click.option("--list", "list_transforms", is_flag=True, help="List available custom transforms")
@click.option("--help-transform", is_flag=True, help="Show parameters for this transform")
@click.option("--params", multiple=True, help="Transform parameters as KEY=VALUE (can be repeated)")
@click.option("--view", is_flag=True, help="Save visualization PNG with constellation plot")
@click.option("--overwrite", is_flag=True, help="Overwrite output if it exists")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--quiet", "-q", is_flag=True, help="Suppress output")
def custom(
transform_name,
input,
output,
transform_dir,
list_transforms,
help_transform,
params,
view,
overwrite,
verbose,
quiet,
):
"""Apply custom user-defined transforms to recordings.
Custom transforms are Python functions loaded from user-specified directory.
Each .py file in the directory is scanned for public functions that can be used.
Transform functions must have signature:
def my_transform(signal, **kwargs) -> signal_or_recording
where signal is a complex CxN array or Recording object.
Examples:
# List all custom transforms in directory
\b
utils transform custom --transform-dir ~/my_transforms --list
# Show parameters for a transform
\b
utils transform custom my_filter --transform-dir ~/my_transforms --help-transform
# Apply custom transform
\b
utils transform custom my_filter input.npy --transform-dir ~/my_transforms
# With parameters and visualization
\b
utils transform custom my_filter input.npy --transform-dir ~/my_transforms \\
--params cutoff_freq=5000 order=4 --view
"""
try:
available = load_custom_transforms(transform_dir)
except click.ClickException:
raise
if list_transforms:
click.echo(f"Available custom transforms in {transform_dir}:")
for name in sorted(available.keys()):
func = available[name]
source_file = getattr(func, "_transform_source_file", "unknown")
docstring = (func.__doc__ or "").split("\n")[0].strip()
click.echo(f" {name:30} {docstring:40} [{source_file}]")
return
if help_transform:
check_input_errors("transform_name", transform_name, available, input, help_transform)
show_transform_help(transform_name, available[transform_name])
return
check_input_errors("transform_name", transform_name, available, input, help_transform)
# Generate output filename if not provided
if output is None:
input_path = Path(input)
input_stem = input_path.stem
ext = input_path.suffix
suffix = generate_transform_suffix(transform_name, parse_transform_params(params))
output = str(input_path.parent / f"{input_stem}_{suffix}{ext}")
echo_verbose(f"Auto-generated output: {output}", verbose)
# Check if output exists
if not overwrite and Path(output).exists():
raise click.ClickException(f"Output file '{output}' already exists\n" f"Use --overwrite to replace")
echo_progress(f"Applying custom: {os.path.basename(input)}{os.path.basename(output)}", quiet)
echo_verbose(f"Transform: {transform_name}", verbose)
# Load input
recording = load_input(input, verbose)
# Parse and apply transform
try:
transform_func = available[transform_name]
transform_params = parse_transform_params(params)
echo_verbose(f"Parameters: {transform_params}", verbose)
result = transform_func(recording, **transform_params)
except Exception as e:
raise click.ClickException(f"Transform failed: {e}")
# Track transform in metadata
updated_metadata = result.metadata.copy()
if "transforms_applied" not in updated_metadata:
updated_metadata["transforms_applied"] = []
updated_metadata["transforms_applied"].append(
{
"type": "custom",
"name": transform_name,
"source_file": getattr(available[transform_name], "_transform_source_file", "unknown"),
"params": parse_transform_params(params),
}
)
# Create new recording with updated metadata
result = Recording(data=result.data, metadata=updated_metadata, annotations=result.annotations)
# Save output
try:
save_recording(result, output, overwrite=overwrite, verbose=verbose)
echo_progress(f"Saved to: {output}", quiet)
except Exception as e:
raise click.ClickException(f"Failed to save output: {e}")
# Optional: Create visualization
if view:
echo_verbose("Creating visualization...", verbose)
quick_view_transform(result, output, title=f"{transform_name.replace('_', ' ').title()} - {Path(output).name}")