From 11d9532b5c7bb6b90df4907739c2620677d3b16d Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 31 Mar 2026 13:34:00 -0400 Subject: [PATCH] Port annotation system from utils and fix ria package imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotations package (new): - Add threshold_qualifier with 3-pass hysteresis detector (Pass 1: strong bursts, Pass 2: weak residual bursts, Pass 3: macro-window faint burst detection), auto window_size scaled to 1ms, channel selection, and stable noise_floor baseline throughout - Add energy_detector, cusum_annotator, parallel_signal_separator, qualify_slice, signal_isolation, annotation_transforms - Add __init__.py exporting the four functions used by the CLI - Fix all imports from utils.data → ria_toolkit_oss.datatypes CLI annotate command (new): - Port full annotate CLI from utils including list, add, remove, clear, energy, cusum, threshold, and separate subcommands - Fix imports from utils.* → ria_toolkit_oss.* and utils_cli.* → ria_toolkit_oss_cli.* - Safe overwrite logic: _annotated files always writable, originals protected; --overwrite writes in-place only on _annotated inputs CLI view command: - Add 'annotations' as a valid --type, wiring view_annotations from view_signal view_signal.py: - Add view_annotations function with blue/purple alternating palette and threshold %-sorted drawing order (lower % renders on top) recording.py (datatypes): - Fix lazy imports in to_wav() and to_blue() from utils.io → ria_toolkit_oss.io io/recording.py: - Add compatibility shim in from_npy to remap utils.data.annotation.Annotation to ria_toolkit_oss.datatypes.annotation.Annotation when loading .npy files pickled by the utils package --- .../datatypes/radio_datasets.rst | 8 +- src/ria_toolkit_oss/annotations/__init__.py | 4 + .../annotations/annotation_transforms.py | 55 ++ .../annotations/cusum_annotator.py | 203 +++++ .../annotations/energy_detector.py | 438 ++++++++++ .../annotations/parallel_signal_separator.py | 435 ++++++++++ .../annotations/qualify_slice.py | 35 + .../annotations/signal_isolation.py | 97 +++ .../annotations/threshold_qualifier.py | 352 ++++++++ src/ria_toolkit_oss/datatypes/recording.py | 4 +- src/ria_toolkit_oss/io/recording.py | 21 + .../Qoherent-logo-black-transparent.png | Bin 92294 -> 130 bytes .../Qoherent-logo-white-transparent.png | Bin 19826 -> 130 bytes src/ria_toolkit_oss/view/view_signal.py | 78 ++ .../ria_toolkit_oss/annotate.py | 820 ++++++++++++++++++ .../ria_toolkit_oss/commands.py | 1 + .../ria_toolkit_oss/generate.py | 4 +- .../ria_toolkit_oss/transform.py | 6 +- .../ria_toolkit_oss/view.py | 3 +- tests/ria_toolkit_oss_cli/README.md | 2 +- tests/ria_toolkit_oss_cli/__init__.py | 2 +- 21 files changed, 2554 insertions(+), 14 deletions(-) create mode 100644 src/ria_toolkit_oss/annotations/__init__.py create mode 100644 src/ria_toolkit_oss/annotations/annotation_transforms.py create mode 100644 src/ria_toolkit_oss/annotations/cusum_annotator.py create mode 100644 src/ria_toolkit_oss/annotations/energy_detector.py create mode 100644 src/ria_toolkit_oss/annotations/parallel_signal_separator.py create mode 100644 src/ria_toolkit_oss/annotations/qualify_slice.py create mode 100644 src/ria_toolkit_oss/annotations/signal_isolation.py create mode 100644 src/ria_toolkit_oss/annotations/threshold_qualifier.py create mode 100644 src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py diff --git a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst index 149fbaf..95d47e2 100644 --- a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst +++ b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst @@ -11,15 +11,15 @@ The Radio Dataset Framework provides a software interface to access and manipula the need for users to interface with the source files directly. Instead, users initialize and interact with a Python object, while the complexities of efficient data retrieval and source file manipulation are managed behind the scenes. -Utils includes an abstract class called :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`, which defines common properties and +Ria Toolkit OSS includes an abstract class called :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`, which defines common properties and behaviors for all radio datasets. :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset` can be considered a blueprint for all other radio dataset classes. This class is then subclassed to define more specific blueprints for different types of radio datasets. For example, :py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset`, which is tailored for machine learning tasks involving the processing of signals represented as IQ (In-phase and Quadrature) samples. -Then, in the various project backends, there are concrete dataset classes, which inherit from both Utils and the base +Then, in the various project backends, there are concrete dataset classes, which inherit from both Ria Toolkit OSS and the base dataset class from the respective backend. For example, the :py:obj:`TorchIQDataset` class extends both -:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from Utils and :py:obj:`torch.ria_toolkit_oss.datatypes.IterableDataset` from +:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from Ria Toolkit OSS and :py:obj:`torch.ria_toolkit_oss.datatypes.IterableDataset` from PyTorch, providing a concrete dataset class tailored for IQ datasets and optimized for the PyTorch backend. Dataset initialization @@ -130,7 +130,7 @@ Dataset processing and manipulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ All radio datasets support methods tailored specifically for radio processing. These methods are backend-independent, -inherited from the blueprints in Utils like :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`. +inherited from the blueprints in Ria Toolkit OSS like :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`. For example, we can trim down the length of the examples from 1,024 to 512 samples, and then augment the dataset: diff --git a/src/ria_toolkit_oss/annotations/__init__.py b/src/ria_toolkit_oss/annotations/__init__.py new file mode 100644 index 0000000..e64c37f --- /dev/null +++ b/src/ria_toolkit_oss/annotations/__init__.py @@ -0,0 +1,4 @@ +from .cusum_annotator import annotate_with_cusum +from .energy_detector import detect_signals_energy +from .parallel_signal_separator import split_recording_annotations +from .threshold_qualifier import threshold_qualifier diff --git a/src/ria_toolkit_oss/annotations/annotation_transforms.py b/src/ria_toolkit_oss/annotations/annotation_transforms.py new file mode 100644 index 0000000..47300c1 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/annotation_transforms.py @@ -0,0 +1,55 @@ +from ria_toolkit_oss.datatypes.annotation import Annotation + +# TODO figure out how to transfer labels in the merge case + + +def remove_contained_boxes(annotations: list[Annotation]): + """ + Remove all annotations (bounding boxes) that are entirely contained within other boxes in the list. + + :param annotations: A list of Annotation objects. + :type annotations: list[Annotation] + + :returns: A new list of Annotation objects. + :rtype: list[Annotation]""" + + output_boxes = [] + + for i in range(len(annotations)): + contained = False + for j in range(len(annotations)): + if i != j and is_annotation_contained(annotations[i], annotations[j]): + contained = True + break + + if not contained: + output_boxes.append(annotations[i]) + + return output_boxes + + +def is_annotation_contained(inner: Annotation, outer: Annotation) -> bool: + """ + Check if an annotation box is entirely contained within another annotation bounding box. + + :param inner: The inner box. + :type inner: Annotation. + :param outer: The outer box. + :type outer: Annotation. + + :returns: True if inner is within outer, false otherwise. + :rtype: bool + """ + + inner_sample_stop = inner.sample_start + inner.sample_count + outer_sample_stop = outer.sample_start + outer.sample_count + + if inner.sample_start > outer.sample_start and inner_sample_stop < outer_sample_stop: + if inner.freq_lower_edge > outer.freq_lower_edge and inner.freq_upper_edge < outer.freq_upper_edge: + return True + + return False + + +def merge_annotations(annotations: list[Annotation], overlap_threshold) -> list[Annotation]: + raise NotImplementedError diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py new file mode 100644 index 0000000..d37186c --- /dev/null +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -0,0 +1,203 @@ +import json +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def annotate_with_cusum( + recording: Recording, + label: Optional[str] = "segment", + window_size: Optional[int] = 1, + min_duration: Optional[float] = None, + tolerance: Optional[int] = None, + annotation_type: Optional[str] = "standalone", +): + """ + Add annotations that divide the recording into distinct time segments. + + This algorithm computes the cumulative sum of the sample magnitudes and + determines break points in the signal. + + This tool can be used to find points where a signal turns on or off, or + changes between a low and high amplitude. + + :param recording: A ``Recording`` object to annotate. + :type recording: ``ria_toolkit_oss.datatypes.Recording`` + :param label: Label for the detected segments. + :type label: str + :param window_size: The length (in samples) of the moving average window. + :type window_size: int + :param min_duration: The minimum duration (in ms) of a segment. + The algorithm will not produce annotations shorter than this length. + :type min_duration: float + :param tolerance: The minimum length (in samples) of a segment. + :type tolerance: int + :param annotation_type: Annotation type (standalone, parallel, intersection). + :type annotation_type: str + """ + + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Create an object of the time segmenter + time_segmenter = TimeSegmenter(sample_rate, min_duration, window_size, tolerance) + + change_points = time_segmenter.apply(recording.data[0]) + + time_segments_indices = np.append(np.insert(change_points, 0, 0), len(recording.data[0])) + annotations = [] + for i in range(len(time_segments_indices) - 1): + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "cusum_annotator", + "params": { + "window_size": window_size, + "min_duration": min_duration, + "tolerance": tolerance, + }, + } + f_min, f_max = detect_frequency( + signal=recording.data[0], + start=time_segments_indices[i], + stop=time_segments_indices[i + 1], + sample_rate=sample_rate, + ) + + annotations.append( + Annotation( + sample_start=time_segments_indices[i], + sample_count=time_segments_indices[i + 1] - time_segments_indices[i], + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "cusum_annotator"}, + ) + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def _compute_cusum(_signal, sample_rate: int, tolerance: int = None, min_duration: float = -1): + """ + This function efficiently computes the cumulative sum of a give list (_signal), with an optional tolerance. + + Args: + - _signal: array of iq samples. + - Tolerance: the least acceptable length of a block, Defaults to None. + + Returns: + - cusum (array): Array of the cumulative sum of the given list + - sample_rate (int): __description_ + - change_points (array): Array of the indices at which a change in the CUSUM direction happens. + - min_duration (float): The least acceptable time width of each segment (in ms). Defaults to -1. + """ + + # efficiently calculate the running sum of the signal + # cusum = list(itertools.accumulate((_signal - np.mean(_signal)))) + x = _signal - np.mean(_signal) + cusum = np.cumsum(x) + + # 'diff' computes the differences between the consecutive values, + # then 'sign' determines if it is +ve or -ve. + change_indicators = np.sign(np.diff(cusum)) + change_points = np.where(np.diff(change_indicators))[0] + 1 + + # Limit the change_points + # Reject those whose number of samples < minimum accepted #n of samples in (min duration) ms. + if min_duration is not None and min_duration > 0: + min_samples_wide = int(min_duration * sample_rate / 1000) + segments_lengths = np.diff(change_points) + segments_lengths = np.insert(segments_lengths, 0, change_points[0]) + change_points = change_points[np.where(segments_lengths > min_samples_wide)[0]] + return cusum, change_points + + +def detect_frequency(signal, start, stop, sample_rate): + signal_segment = signal[start:stop] + if len(signal_segment) > 0: + fft_data = np.abs(np.fft.fftshift(np.fft.fft(signal_segment))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Use a spectral threshold to find the 'height' of the orange block + spectral_thresh = np.max(fft_data) * 0.15 + sig_indices = np.where(fft_data > spectral_thresh)[0] + + if len(sig_indices) > 4: + return fft_freqs[sig_indices[0]], fft_freqs[sig_indices[-1]] + else: + return -sample_rate / 4, sample_rate / 4 + else: + return -sample_rate / 4, sample_rate / 4 + + +class TimeSegmenter: + """Time Segmenter class, it creates a segmenter object with certain\ + characteristics to easily split an input signal to segments based on\ + the cumulative sum of deviations (of the signal mean) + """ + + def __init__( + self, sample_rate: int, min_duration: float = 1, moving_average_window: int = 3, tolerance: int = None + ): + """_summary_ + + Args: + sample_rate (int): _description_ + min_duration (float, optional): _description_. Defaults to 1. + moving_average_window (int, optional): _description_. Defaults to 3. + tolerance (int, optional): _description_. Defaults to None. + """ + self.sample_rate = sample_rate + self.min_duration = min_duration + self.moving_average_window = moving_average_window + self._moving_avg_filter = self._init_filter() + self.tolerance = tolerance + + def _init_filter(self): + """_summary_ + + Returns: + _type_: _description_ + """ + return np.ones(self.moving_average_window) / self.moving_average_window + + def _apply_filter(self, iqsignal: np.array): + """_summary_ + + Args: + iqsignal (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.convolve(abs(iqsignal), self._moving_avg_filter, mode="same") + + def _create_segments(self, iq_signal: np.array, change_points: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + change_points (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.split(iq_signal, change_points) + + def apply(self, iq_signal: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + + Returns: + _type_: _description_ + """ + smoothed_signal = self._apply_filter(iq_signal) + _, change_points = _compute_cusum(smoothed_signal, self.sample_rate, self.tolerance, self.min_duration) + # segments = self._create_segments(iq_signal, change_points) + return change_points diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py new file mode 100644 index 0000000..98329d5 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -0,0 +1,438 @@ +""" +Energy-based signal detection and bandwidth analysis. + +Provides automatic annotation generation using energy-based signal detection +and occupied bandwidth calculation following ITU-R SM.328 standard. +""" + +import json +from typing import Tuple + +import numpy as np +from scipy.signal import filtfilt + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def detect_signals_energy( + recording: Recording, + k: int = 10, + threshold_factor: float = 1.2, + window_size: int = 200, + min_distance: int = 5000, + label: str = "signal", + annotation_type: str = "standalone", + freq_method: str = "nbw", + nfft: int = None, + obw_power: float = 0.99, +) -> Recording: + """ + Detect signal bursts using energy-based method with adaptive noise floor estimation. + + This algorithm smooths the signal with a moving average filter, estimates the noise + floor from k segments, applies a threshold to detect regions above noise, and merges + nearby detections. Detected time boundaries are then assigned frequency bounds based + on the selected frequency method. + + Time Detection Algorithm: + 1. Smooth signal using moving average (envelope detection) + 2. Divide smoothed signal into k segments + 3. Estimate noise floor as median of segment mean powers + 4. Detect regions where power exceeds threshold_factor * noise_floor + 5. Merge regions closer than min_distance samples + + Frequency Bounding (freq_method): + - 'nbw': Nominal bandwidth (OBW + center frequency) - DEFAULT + - 'obw': Occupied bandwidth (99.99% power, includes siedelobes) + - 'full-detected': Lowest to highest spectral component + - 'full-bandwidth': Entire Nyquist span (center_freq ± sample_rate/2) + + :param recording: Recording to analyze + :type recording: Recording + :param k: Number of segments for noise floor estimation (default: 10) + :type k: int + :param threshold_factor: Threshold multiplier above noise floor (typical: 1.2-2.0, default: 1.2) + :type threshold_factor: float + :param window_size: Moving average window size in samples (default: 200) + :type window_size: int + :param min_distance: Minimum distance between separate signals in samples (default: 5000) + :type min_distance: int + :param label: Label for detected annotations (default: "signal") + :type label: str + :param annotation_type: Annotation type (standalone, parallel, intersection, default: standalone) + :type annotation_type: str + :param freq_method: How to calculate frequency bounds (default: 'nbw') + :type freq_method: str + :param nfft: FFT size for frequency calculations (default: None) + :type nfft: int + :param obw_power: Power percentage for OBW (0.9999 = 99.99%, default: 0.99) + :type obw_power: float + + :returns: New Recording with added annotations + :rtype: Recording + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import detect_signals_energy + >>> recording = load_recording("capture.sigmf") + + >>> # Detect with NBW frequency bounds (default, best for real signals) + >>> annotated = detect_signals_energy(recording, label="burst") + + >>> # Detect with OBW (more conservative, includes siedelobes) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="obw" + ... ) + + >>> # Detect with full detected range (captures all spectral components) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="full-detected" + ... ) + """ + # Extract signal data (use first channel only) + signal = recording.data[0] + + # Calculate smoothed signal power + kernel = np.ones(window_size) / window_size + smoothed_power = filtfilt(kernel, [1], np.abs(signal) ** 2) + + # Estimate noise floor using segment-based median (robust to signal presence) + segments = np.array_split(smoothed_power, k) + noise_floor = np.median([np.mean(s) for s in segments]) + + # Detect signal boundaries (regions above threshold) + enter = noise_floor * threshold_factor + exit = enter * 0.8 + boundaries = [] + start = None + active = False + + for i, p in enumerate(smoothed_power): + if not active and p > enter: + start = i + active = True + elif active and p < exit: + boundaries.append((start, i - start)) + active = False + + if active: + boundaries.append((start, len(smoothed_power) - start)) + + # Merge boundaries that are closer than min_distance + merged_boundaries = [] + if boundaries: + start, length = boundaries[0] + for next_start, next_length in boundaries[1:]: + if next_start - (start + length) < min_distance: + # Merge with current boundary + length = next_start + next_length - start + else: + # Save current and start new boundary + merged_boundaries.append((start, length)) + start, length = next_start, next_length + # Add final boundary + merged_boundaries.append((start, length)) + + # Create annotations from detected boundaries + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Validate frequency method + valid_freq_methods = ["nbw", "obw", "full-detected", "full-bandwidth"] + if freq_method not in valid_freq_methods: + raise ValueError(f"Invalid freq_method '{freq_method}'. " f"Must be one of: {', '.join(valid_freq_methods)}") + + annotations = [] + for start_sample, sample_count in merged_boundaries: + # Calculate frequency bounds based on method + freq_lower, freq_upper = calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power + ) + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "energy_detector", + "freq_method": freq_method, + "params": { + "threshold_factor": threshold_factor, + "window_size": window_size, + "noise_floor": float(noise_floor), + "threshold": float(enter), + }, + } + + anno = Annotation( + sample_start=start_sample, + sample_count=sample_count, + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "energy_detector", "freq_method": freq_method}, + ) + annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def calculate_occupied_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +): + if nfft is None: + nfft = max(65536, 2 ** int(np.floor(np.log2(len(signal))))) + + window = np.blackman(len(signal)) + spec = np.fft.fftshift(np.fft.fft(signal * window, n=nfft)) + + psd = np.abs(spec) ** 2 + psd = psd / psd.sum() # normalize + + freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + cdf = np.cumsum(psd) + + tail = (1 - power_percentage) / 2 + + lower_idx = np.searchsorted(cdf, tail) + upper_idx = np.searchsorted(cdf, 1 - tail) + + return freqs[upper_idx] - freqs[lower_idx], freqs[lower_idx], freqs[upper_idx] + + +def calculate_nominal_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +) -> Tuple[float, float]: + """ + Calculate nominal bandwidth and center frequency. + + Nominal bandwidth (NBW) is the occupied bandwidth along with the center + frequency of the signal's spectral occupancy. Useful for characterizing + signals with unknown or drifting center frequencies. + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param power_percentage: Fraction of power to contain + :type power_percentage: float + + :returns: Tuple of (nominal_bandwidth_hz, center_frequency_hz) + :rtype: Tuple[float, float] + + **Example**:: + + >>> from utils.annotations import calculate_nominal_bandwidth + >>> nbw, center = calculate_nominal_bandwidth(signal, sampling_rate=10e6) + >>> print(f"NBW: {nbw/1e6:.3f} MHz, Center: {center/1e6:.3f} MHz") + """ + bw, lower_freq, upper_freq = calculate_occupied_bandwidth(signal, sampling_rate, nfft, power_percentage) + + # Center frequency is midpoint of occupied band + center_freq = (lower_freq + upper_freq) / 2 + + return lower_freq, upper_freq, center_freq + + +def calculate_full_detected_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + start_offset: int = 1000, +) -> Tuple[float, float, float]: + """ + Calculate frequency range from lowest to highest spectral component. + + Unlike OBW/NBW which define a power-based bandwidth, this calculates + the absolute frequency span from the lowest non-zero spectral component + to the highest non-zero component. + + Useful for: + - Signals with spectral gaps + - Multiple parallel signals (captures all of them) + - Understanding total occupied spectrum vs. actual bandwidth + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param start_offset: Skip samples at start + :type start_offset: int + + :returns: Tuple of (bandwidth_hz, lower_freq_hz, upper_freq_hz) + :rtype: Tuple[float, float, float] + + **Example**:: + + >>> # Signal with two components at different frequencies + >>> bw, f_low, f_high = calculate_full_detected_bandwidth( + ... signal, sampling_rate=10e6, nfft=65536 + ... ) + >>> print(f"Full span: {f_low/1e6:.3f} to {f_high/1e6:.3f} MHz") + """ + # Validate input + if len(signal) < nfft + start_offset: + raise ValueError( + f"Signal too short: need {nfft + start_offset} samples, " + f"got {len(signal)}. Reduce nfft or start_offset." + ) + + # Extract segment + signal_segment = signal[start_offset : nfft + start_offset] + + # Compute FFT and power spectral density + freq_spectrum = np.fft.fft(signal_segment, n=nfft) + psd = np.abs(freq_spectrum) ** 2 + + # Shift to center DC + psd_shifted = np.fft.fftshift(psd) + freq_bins = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + # Find noise floor (mean of lowest 10% of bins) and all bins above noise floor + noise_floor = np.mean(np.sort(psd_shifted)[: int(len(psd_shifted) * 0.1)]) + above_noise = np.where(psd_shifted > noise_floor * 1.5)[0] + + if len(above_noise) == 0: + # No signal above noise, return zero bandwidth + return 0.0, 0.0, 0.0 + + # Get frequency range of signal components + lower_idx = above_noise[0] + upper_idx = above_noise[-1] + + lower_freq = freq_bins[lower_idx] + upper_freq = freq_bins[upper_idx] + + bandwidth = upper_freq - lower_freq + + return bandwidth, lower_freq, upper_freq + + +def annotate_with_obw( + recording: Recording, + label: str = "signal", + annotation_type: str = "standalone", + nfft: int = None, + power_percentage: float = 0.99, +) -> Recording: + """ + Create a single annotation spanning the occupied bandwidth of the entire recording. + + Analyzes the full recording to find its occupied bandwidth and creates an annotation + covering that frequency range for the entire time duration. + + :param recording: Recording to analyze + :type recording: Recording + :param label: Annotation label + :type label: str + :param annotation_type: Annotation type + :type annotation_type: str + :param nfft: FFT size + :type nfft: int + :param power_percentage: Power percentage for OBW calculation + :type power_percentage: float + + :returns: Recording with OBW annotation added + :rtype: Recording + + **Example**:: + + >>> from ria_toolkit_oss.annotations import annotate_with_obw + >>> annotated = annotate_with_obw(recording, label="signal_obw") + """ + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_freq = recording.metadata.get("center_frequency", 0) + + # Calculate OBW + obw, lower_offset, upper_offset = calculate_occupied_bandwidth(signal, sample_rate, nfft, power_percentage) + + # Convert baseband offsets to absolute frequencies + freq_lower = center_freq + lower_offset + freq_upper = center_freq + upper_offset + + # Create comment JSON + comment_data = { + "type": annotation_type, + "generator": "obw_annotator", + "obw_hz": float(obw), + "power_percentage": power_percentage, + "params": {"nfft": nfft}, + } + + # Create annotation spanning entire recording + anno = Annotation( + sample_start=0, + sample_count=len(signal), + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "obw_annotator", "obw_hz": float(obw)}, + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + [anno]) + + +def calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power +): + if freq_method == "full-bandwidth": + # Full Nyquist span + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Extract segment for frequency analysis + segment_start = start_sample + segment_end = min(start_sample + sample_count, len(signal)) + segment = signal[segment_start:segment_end] + + if nfft is None or len(segment) >= nfft: + if freq_method == "nbw": + # Nominal bandwidth (OBW + center frequency) + try: + lower_freq, upper_freq, _ = calculate_nominal_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + lower_freq + freq_upper = center_frequency + upper_freq + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "obw": + # Occupied bandwidth + try: + _, f_lower, f_upper = calculate_occupied_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "full-detected": + # Full detected range (lowest to highest component) + try: + _, f_lower, f_upper = calculate_full_detected_bandwidth(segment, sample_rate, nfft) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Segment too short for FFT, use full bandwidth + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + return freq_lower, freq_upper diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py new file mode 100644 index 0000000..957cf58 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -0,0 +1,435 @@ +""" +Parallel signal separation for multi-component frequency-offset signals. + +Provides methods to detect and separate overlapping frequency-domain signals +that occupy the same time window but different frequency bands. + +This module implements **spectral peak detection** to identify distinct frequency +components and split single time-domain annotations into frequency-specific +sub-annotations. + +**Key Design Decisions** (per Codex review): + +1. **Complex IQ Support**: Uses `scipy.signal.welch` with `return_onesided=False` + for proper complex signal handling. Window length automatically adapts to + signal length via `nperseg=min(nfft, len(signal))` to handle bursts >> from ria_toolkit_oss.annotations import find_spectral_components + >>> # Detect the two distinct channels (returns relative frequencies) + >>> components = find_spectral_components(signal, sampling_rate=20e6) + >>> print(f"Found {len(components)} components") + Found 2 components + +The module is designed to work with detected time-domain annotations, +allowing splitting of overlapping signals into separate training samples. +""" + +import json +from typing import List, Optional, Tuple + +import numpy as np +from scipy import ndimage +from scipy import signal as scipy_signal + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def find_spectral_components( + signal_data: np.ndarray, + sampling_rate: float, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, + time_percentile: float = 70.0, +) -> List[Tuple[float, float, float]]: + """ + Find distinct frequency components using spectral peak detection. + + Identifies separate frequency components in a signal by analyzing the power + spectral density and finding peaks corresponding to distinct signals. This is + useful for separating parallel signals that occupy different frequency bands. + + **Frequency Representation**: Returns frequencies in **baseband/relative** Hz + (centered at 0). To get absolute RF frequencies, add center_frequency_hz from + recording metadata to all returned values. + + Algorithm: + 1. Compute power spectral density using Welch (properly handles complex IQ) + 2. Auto-estimate noise floor from data if not specified + 3. Smooth PSD to reduce spurious peaks + 4. Find local maxima above noise floor + 5. Estimate bandwidth per peak using -3dB (fallback: cumulative power) + 6. Filter components below minimum bandwidth threshold + + :param signal_data: Complex IQ signal samples (np.complex64/128) + :type signal_data: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size / window length for Welch. Automatically capped at + signal length to handle bursts (default: 65536) + :type nfft: int + :param noise_threshold_db: Minimum SNR threshold in dB. If None (default), + auto-estimates as np.percentile(psd_db, 10). + Adapt this across hardware (Pluto: ~-100, ThinkRF: ~-60). + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + :param power_threshold: Cumulative power threshold for fallback bandwidth + estimation (default: 0.99 = 99% power, like OBW) + :type power_threshold: float + + :returns: List of (center_freq_hz, lower_freq_hz, upper_freq_hz) tuples. + **All frequencies are relative (baseband, 0-centered).** + Add recording metadata['center_frequency'] to get absolute RF frequencies. + :rtype: List[Tuple[float, float, float]] + + :raises ValueError: If signal has fewer than 256 samples + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import find_spectral_components + >>> recording = load_recording("capture.sigmf") + >>> segment = recording.data[0][start:end] + >>> # Components in relative (baseband) frequency + >>> components = find_spectral_components(segment, sampling_rate=20e6) + >>> for center_rel, lower_rel, upper_rel in components: + ... # Convert to absolute RF frequency + ... center_abs = recording.metadata['center_frequency'] + center_rel + ... print(f"Component @ {center_abs/1e9:.3f} GHz") + """ + # Validate input + min_samples = 256 + if len(signal_data) < min_samples: + raise ValueError(f"Signal too short: need at least {min_samples} samples, " f"got {len(signal_data)}.") + + # Compute PSD using Welch method for complex IQ signals + # CRITICAL: return_onesided=False for proper complex signal handling + nperseg = min(nfft, len(signal_data)) + noverlap = nperseg // 2 + + # --- STFT --- + freqs, times, Zxx = scipy_signal.stft( + signal_data, + fs=sampling_rate, + window="blackman", + nperseg=nperseg, + noverlap=noverlap, + return_onesided=False, + boundary=None, + ) + + # Shift zero freq to center + Zxx = np.fft.fftshift(Zxx, axes=0) + freqs = np.fft.fftshift(freqs) + + # Power spectrogram + power = np.abs(Zxx) ** 2 + power_db = 10 * np.log10(power + 1e-12) + + # --- Aggregate across time robustly --- + # Using percentile instead of mean prevents short signals from being diluted + freq_profile_db = np.percentile(power_db, time_percentile, axis=1) + + # --- Noise floor estimation --- + if noise_threshold_db is None: + noise_threshold_db = np.percentile(freq_profile_db, 20) + + threshold = noise_threshold_db + 3 # 3 dB above noise floor + + # --- Smooth lightly (avoid merging nearby signals) --- + freq_profile_db = ndimage.gaussian_filter1d(freq_profile_db, sigma=1.5) + + # --- Binary mask of significant frequencies --- + mask = freq_profile_db > threshold + + # --- Find contiguous frequency regions --- + labeled, num_features = ndimage.label(mask) + + components = [] + + for region_label in range(1, num_features + 1): + region_indices = np.where(labeled == region_label)[0] + + if len(region_indices) == 0: + continue + + lower_idx = region_indices[0] + upper_idx = region_indices[-1] + + lower_freq = freqs[lower_idx] + upper_freq = freqs[upper_idx] + bw = upper_freq - lower_freq + + if bw < min_component_bw: + continue + + center_freq = (lower_freq + upper_freq) / 2 + components.append((center_freq, lower_freq, upper_freq)) + + return components + + +def split_annotation_by_components( + annotation: Annotation, + signal: np.ndarray, + sampling_rate: float, + center_frequency_hz: float = 0.0, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, +) -> List[Annotation]: + """ + Split an annotation into multiple annotations by detected frequency components. + + Takes an existing annotation spanning multiple frequency components and + analyzes the frequency content to create separate sub-annotations for + each distinct frequency component. + + **Use case**: Energy detection found a time window with 2-3 parallel WiFi + channels. This function splits it into separate annotations per channel. + + **Frequency Handling**: `find_spectral_components` returns relative (baseband) + frequencies. This function adds `center_frequency_hz` to convert to absolute + RF frequencies for SigMF annotation bounds. This ensures correct frequency + context across baseband and RF domains. + + :param annotation: Original annotation to split + :type annotation: Annotation + :param signal: Full signal array (complex IQ) + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param center_frequency_hz: RF center frequency to add to relative frequencies + from peak detection (default: 0.0 = baseband) + :type center_frequency_hz: float + :param nfft: FFT size for analysis (default: 65536, auto-capped at signal length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from data. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + + :returns: List of new annotations (one per detected component). + Returns empty list if no components found or segment too short. + :rtype: List[Annotation] + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import split_annotation_by_components + >>> recording = load_recording("capture.sigmf") + >>> # Original annotation spans multiple channels + >>> original = recording.annotations[0] + >>> # Split using RF center frequency from metadata + >>> components = split_annotation_by_components( + ... original, + ... recording.data[0], + ... recording.metadata['sample_rate'], + ... center_frequency_hz=recording.metadata.get('center_frequency', 0.0) + ... ) + >>> print(f"Split into {len(components)} components") + Split into 2 components + + **Algorithm**: + 1. Extract segment corresponding to annotation time bounds + 2. Find frequency components in that segment (returns relative frequencies) + 3. Add center_frequency_hz to get absolute RF frequencies + 4. Create new annotation for each component + 5. Preserve original metadata (label, type, etc.) + 6. Add component info to comment JSON + + **Notes**: + - Original annotation is not modified + - Returns empty list if segment too short (<256 samples) + - Segments Recording: + """ + Split multiple annotations in a recording by frequency components. + + Processes specified annotations (or all if indices=None), replacing each + with its frequency-separated components. Uses RF center_frequency from + recording metadata for proper absolute frequency conversion. + + :param recording: Recording to process + :type recording: Recording + :param indices: Annotation indices to split (None = all, default: None). + Use indices=[] to skip splitting (returns unchanged recording). + :type indices: Optional[List[int]] + :param nfft: FFT size for spectral analysis (default: 65536, + auto-capped at signal segment length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from each segment. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz). + Components narrower than this are filtered out. + :type min_component_bw: float + + :returns: New Recording with split annotations + :rtype: Recording + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import split_recording_annotations + >>> recording = load_recording("capture.sigmf") + >>> # Split all annotations + >>> split_rec = split_recording_annotations(recording) + >>> print(f"Original: {len(recording.annotations)} annotations") + >>> print(f"Split: {len(split_rec.annotations)} annotations") + Original: 5 annotations + Split: 9 annotations + + **Algorithm**: + 1. For each annotation in indices (or all if None): + 2. Call split_annotation_by_components with RF center_frequency + 3. If components found, replace annotation with components + 4. If no components found, keep original annotation + 5. Annotations not in indices are kept unchanged + + **Notes**: + - Original recording is not modified + - Returns empty Recording.annotations if recording has no annotations + - RF center_frequency from metadata ensures correct absolute frequencies + - If an annotation can't be split (too short, wrong format), original kept + """ + if indices is None: + # Split all annotations + indices = list(range(len(recording.annotations))) + + if not recording.annotations: + # No annotations to split + return recording + + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0.0) + + # Build new annotation list + new_annotations = [] + for i, anno in enumerate(recording.annotations): + if i in indices: + # Attempt to split this annotation + try: + components = split_annotation_by_components( + anno, + signal, + sample_rate, + center_frequency_hz=center_frequency, + nfft=nfft, + noise_threshold_db=noise_threshold_db, + min_component_bw=min_component_bw, + ) + if components: + # Split successful, use components + new_annotations.extend(components) + else: + # No components found, keep original + new_annotations.append(anno) + except Exception: + # Split failed for any reason, keep original + new_annotations.append(anno) + else: + # Not in split list, keep as-is + new_annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=new_annotations) diff --git a/src/ria_toolkit_oss/annotations/qualify_slice.py b/src/ria_toolkit_oss/annotations/qualify_slice.py new file mode 100644 index 0000000..2336fe5 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/qualify_slice.py @@ -0,0 +1,35 @@ +import numpy as np + +from ria_toolkit_oss.datatypes import Recording + + +def qualify_slice_from_annotations(recording: Recording, slice_length: int): + """ + Slice a recording into many smaller recordings, + discarding any slices which do not have annotations that apply to those samples. + Used together with an annotation based qualifier. + + :param recording: The recording to slice. + :type recording: Recording + :param slice_length: The length in samples of a slice. + :type slice_length: int""" + + if len(recording.annotations) == 0: + print("Warning, no annotations.") + + annotation_mask = np.zeros(len(recording.data[0])) + + for annotation in recording.annotations: + annotation_mask[annotation.sample_start : annotation.sample_start + annotation.sample_count] = 1 + + output_recordings = [] + + for i in range((len(recording.data[0]) // slice_length) - 1): + start_index = slice_length * i + end_index = slice_length * (i + 1) + + if 1 in annotation_mask[start_index:end_index]: + sl = recording.data[:, start_index:end_index] + output_recordings.append(Recording(data=sl, metadata=recording.metadata)) + + return output_recordings diff --git a/src/ria_toolkit_oss/annotations/signal_isolation.py b/src/ria_toolkit_oss/annotations/signal_isolation.py new file mode 100644 index 0000000..47852ae --- /dev/null +++ b/src/ria_toolkit_oss/annotations/signal_isolation.py @@ -0,0 +1,97 @@ +import numpy as np +from scipy.signal import butter, lfilter + +from ria_toolkit_oss.datatypes.annotation import Annotation +from ria_toolkit_oss.datatypes.recording import Recording + + +def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: + """ + Slice, filter and frequency shift the input recording according to the bounding box defined by the annotation. + + :param recording: The input Recording to be sliced. + :type recording: Recording + :param annotation: The Annotation object defining the area of the recording to isolate. + :type annotation: Annotation + :param decimate: Decimate the input signal after filtering to reduce the sample rate. + :type decimate: bool + + :returns: The subsection of the original recording defined by the annotation. + :rtype: Recording""" + + sample_start = max(0, annotation.sample_start) + sample_stop = min(len(recording), annotation.sample_start + annotation.sample_count) + + anno_base_center_freq = (annotation.freq_lower_edge + annotation.freq_upper_edge) / 2 - recording.metadata.get( + "center_frequency", 0 + ) + + anno_bw = annotation.freq_upper_edge - annotation.freq_lower_edge + + signal_slice = recording.data[0, sample_start:sample_stop] + + # normalize + signal_slice = signal_slice / np.max(np.abs(signal_slice)) + + isolation_bw = anno_bw + + # frequency shift the center of the box about zero + shifted_signal_slice = frequency_shift_iq_samples( + iq_samples=signal_slice, + sample_rate=recording.metadata["sample_rate"], + shift_frequency=-1 * anno_base_center_freq, + ) + + # filter + if isolation_bw < recording.metadata["sample_rate"] - 1: + filtered_signal = apply_complex_lowpass_filter( + signal=shifted_signal_slice, cutoff_frequency=isolation_bw, sample_rate=recording.metadata["sample_rate"] + ) + + else: + filtered_signal = shifted_signal_slice + + output = Recording(data=[filtered_signal], metadata=recording.metadata) + return output + + +def frequency_shift_iq_samples(iq_samples, sample_rate, shift_frequency): + # Number of samples + num_samples = len(iq_samples) + + # Create a time vector from 0 to the total duration in seconds + time_vector = np.arange(num_samples) / sample_rate + + # Generate the complex exponential for the frequency shift + complex_exponential = np.exp(1j * 2 * np.pi * shift_frequency * time_vector) + + # Apply the frequency shift to the IQ samples + shifted_samples = iq_samples * complex_exponential + + return shifted_samples + + +# Function to apply a lowpass Butterworth filter to a complex signal +def apply_complex_lowpass_filter(signal, cutoff_frequency, sample_rate, order=5): + # Design the lowpass filter + b, a = design_complex_lowpass_filter(cutoff_frequency, sample_rate, order) + + # Apply the lowpass filter + filtered_signal = lfilter(b, a, signal) + return filtered_signal + + +def design_complex_lowpass_filter(cutoff_frequency, sample_rate, order=5): + # Nyquist frequency for complex signals is the sample rate + nyquist = sample_rate + + # Ensure the cutoff frequency is positive and within the Nyquist limit + if cutoff_frequency <= 0 or cutoff_frequency > nyquist: + raise ValueError("Cutoff frequency must be between 0 and the Nyquist frequency.") + + # Normalize the cutoff frequency to the Nyquist frequency + cutoff_normalized = cutoff_frequency / nyquist + + # Create a Butterworth lowpass filter + b, a = butter(order, cutoff_normalized, btype="low") + return b, a diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py new file mode 100644 index 0000000..338f13c --- /dev/null +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -0,0 +1,352 @@ +""" +Temporal signal detection and boundary refinement via Hysteresis Thresholding. + +Provides methods to detect signal bursts in the time domain by triggering on +smoothed power peaks and expanding boundaries to capture the full energy envelope. + +This module implements a **dual-threshold trigger** to solve the 'chatter' +problem in noisy environments, ensuring that signal annotations encapsulate +the entire rise and fall of a burst rather than just the peak. + +**Key Design Decisions**: + +1. **Hysteresis Logic (Dual-Threshold)**: + - **Trigger**: High threshold (`threshold * max_power`) ensures high confidence + in signal presence. + - **Boundary**: Low threshold (`0.5 * trigger`) allows the annotation to + "crawl" outward, capturing the lower-energy start and end of the burst + often missed by simple single-threshold detectors. + +2. **Temporal Smoothing**: Uses a moving average window (`window_size`) prior + - to thresholding. This prevents high-frequency noise spikes from causing + fragmented annotations and provides a more stable estimate of the + signal's power envelope. + +3. **Spectral Profiling**: Once a temporal segment is isolated, the module + - performs an automated FFT analysis. It identifies the **90% spectral + occupancy** to define the frequency boundaries (`f_min`, `f_max`), + allowing the detector to work on narrowband and wideband signals without + manual frequency tuning. + +4. **Baseband/RF Mapping**: Automatically handles the conversion from + - relative FFT bin frequencies to absolute RF frequencies by referencing + `recording.metadata["center_frequency"]`. + +5. **False Positive Mitigation**: Implements a hard minimum duration check + - (10ms) to ignore transient hardware spikes or noise floor fluctuations + that do not constitute a valid signal burst. + +The module is designed to be the primary "first-pass" detector for pulsed +waveforms (like ADS-B, Lora, or bursty FSK) before passing them to +classification or demodulation stages. +""" + +import json +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def _find_ranges(indices, max_gap): + """ + Groups individual indices into continuous temporal ranges. + + Args: + indices: Array of indices where the signal exceeded a threshold. + max_gap: Maximum gap allowed between indices to consider them part + of the same range. + + Returns: + A list of (start, stop) tuples representing detected signal segments. + """ + + if len(indices) == 0: + return [] + + start = indices[0] + prev = indices[0] + ranges = [] + + for i in range(1, len(indices)): + if indices[i] - prev > max_gap: + ranges.append((start, prev)) + start = indices[i] + prev = indices[i] + + ranges.append((start, prev)) + + return ranges + + +def _expand_and_filter_ranges( + smoothed_power: np.ndarray, + initial_ranges: list[tuple[int, int]], + boundary_val: float, + min_duration_samples: int, +) -> list[tuple[int, int]]: + """Apply hysteresis expansion and minimum-duration filtering.""" + out: list[tuple[int, int]] = [] + n = len(smoothed_power) + for start, stop in initial_ranges: + if (stop - start) < min_duration_samples: + continue + + true_start = start + while true_start > 0 and smoothed_power[true_start] > boundary_val: + true_start -= 1 + + true_stop = stop + while true_stop < n - 1 and smoothed_power[true_stop] > boundary_val: + true_stop += 1 + + if (true_stop - true_start) >= min_duration_samples: + out.append((true_start, true_stop)) + return out + + +def _merge_ranges(ranges: list[tuple[int, int]], max_gap: int) -> list[tuple[int, int]]: + """Merge overlapping or near-adjacent ranges.""" + if not ranges: + return [] + ranges = sorted(ranges, key=lambda r: r[0]) + merged = [ranges[0]] + for s, e in ranges[1:]: + last_s, last_e = merged[-1] + if s <= last_e + max_gap: + merged[-1] = (last_s, max(last_e, e)) + else: + merged.append((s, e)) + return merged + + +def _estimate_noise_floor(power: np.ndarray, quantile: float = 20.0) -> float: + """Estimate baseline from the quieter portion of the envelope.""" + return float(np.percentile(power, quantile)) + + +def _estimate_group_gap(sample_rate: float) -> int: + """Use a fixed temporal grouping gap instead of reusing the smoothing window.""" + return max(1, int(0.001 * sample_rate)) + + +def _estimate_spectral_bounds(signal_segment: np.ndarray, sample_rate: float) -> tuple[float, float]: + """Estimate occupied bandwidth from a smoothed magnitude spectrum.""" + if len(signal_segment) == 0: + return -sample_rate / 4, sample_rate / 4 + + window = np.hanning(len(signal_segment)) + windowed = signal_segment * window + + fft_data = np.abs(np.fft.fftshift(np.fft.fft(windowed))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Smooth the spectrum so noise-like wideband bursts form a contiguous mask + # instead of thousands of tiny isolated runs. + spectral_smooth_bins = max(5, min(257, (len(signal_segment) // 512) | 1)) + spectral_kernel = np.ones(spectral_smooth_bins, dtype=np.float64) / spectral_smooth_bins + smoothed_fft = np.convolve(fft_data, spectral_kernel, mode="same") + + spectral_floor = float(np.percentile(smoothed_fft, 20)) + spectral_peak = float(np.max(smoothed_fft)) + spectral_ratio = spectral_peak / max(spectral_floor, 1e-12) + + if spectral_ratio < 1.2: + return -sample_rate / 4, sample_rate / 4 + + spectral_thresh = spectral_floor + 0.1 * (spectral_peak - spectral_floor) + sig_indices = np.where(smoothed_fft > spectral_thresh)[0] + + if len(sig_indices) == 0: + peak_idx = int(np.argmax(smoothed_fft)) + bin_hz = sample_rate / len(signal_segment) + half_bins = max(1, int(np.ceil(10_000.0 / bin_hz))) + lo_idx = max(0, peak_idx - half_bins) + hi_idx = min(len(smoothed_fft) - 1, peak_idx + half_bins) + else: + runs = _find_ranges(sig_indices, max_gap=max(1, spectral_smooth_bins // 2)) + peak_idx = int(np.argmax(smoothed_fft)) + lo_idx, hi_idx = min(runs, key=lambda run: 0 if run[0] <= peak_idx <= run[1] else min(abs(run[0] - peak_idx), abs(run[1] - peak_idx))) + + # Prevent extremely narrow tone boxes from collapsing to just a few bins. + min_total_bw_hz = 20_000.0 + min_half_bins = max(1, int(np.ceil((min_total_bw_hz / 2) / (sample_rate / len(signal_segment))))) + center_idx = int(round((lo_idx + hi_idx) / 2)) + lo_idx = max(0, min(lo_idx, center_idx - min_half_bins)) + hi_idx = min(len(smoothed_fft) - 1, max(hi_idx, center_idx + min_half_bins)) + + return float(fft_freqs[lo_idx]), float(fft_freqs[hi_idx]) + + +def threshold_qualifier( + recording: Recording, + threshold: float, + window_size: Optional[int] = None, + label: Optional[str] = None, + annotation_type: Optional[str] = "standalone", + channel: int = 0, +) -> Recording: + """ + Annotate a recording with bounding boxes for regions above a threshold. + Threshold is defined as a fraction of the maximum sample magnitude. + This algorithm searches for samples above the threshold and combines them into ranges if they + are within window_size of each other. + Detects and annotates signals using energy thresholding and spectral analysis. + + The algorithm follows these steps: + 1. Smooths power data using a moving average. + 2. Identifies 'peak' regions exceeding a high trigger threshold. + 3. Uses hysteresis to expand boundaries until power drops below a lower threshold. + 4. Performs an FFT on each segment to determine frequency occupancy. + + Args: + recording: The Recording object containing IQ or real signal data. + threshold: Sensitivity multiplier (0.0 to 1.0) applied to max power. + window_size: Size of the smoothing filter in samples. Defaults to 1ms worth of samples. + label: Custom string label for annotations. + annotation_type: Metadata string for the 'type' field in the annotation. + channel: Index of the channel to annotate. Defaults to 0. + + Returns: + A new Recording object populated with detected Annotations. + """ + # Extract signal and metadata + sample_data = recording.data[channel] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + if window_size is None: + window_size = max(64, int(sample_rate * 0.001)) + + # --- 1. SIGNAL CONDITIONING --- + # Convert to power (Magnitude squared) + power_data = np.abs(sample_data) ** 2 + smoothing_window = np.ones(window_size) / window_size + smoothed_power = np.convolve(power_data, smoothing_window, mode="same") + group_gap_samples = _estimate_group_gap(sample_rate) + + # Define thresholds using peak relative to baseline. + max_power = np.max(smoothed_power) + noise_floor = _estimate_noise_floor(smoothed_power) + dynamic_range_ratio = max_power / max(noise_floor, 1e-12) + + # Soft early exit: keep a guard for low-contrast noise, but compute it from + # the quieter tail of the envelope so burst-heavy captures are not rejected. + if dynamic_range_ratio < 1.5: + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations) + + trigger_val = noise_floor + threshold * (max_power - noise_floor) + boundary_val = noise_floor + 0.5 * threshold * (max_power - noise_floor) + + # --- 2. INITIAL DETECTION --- + # Enforce an explicit minimum duration in seconds; this is stable across + # varying capture lengths and avoids over-fitting to recording length. + min_duration_samples = max(1, int(0.005 * sample_rate)) + annotations = [] + + # Pass 1: Detect stronger bursts. + indices = np.where(smoothed_power > trigger_val)[0] + pass1_initial = _find_ranges(indices=indices, max_gap=group_gap_samples) + pass1_ranges = _expand_and_filter_ranges( + smoothed_power=smoothed_power, + initial_ranges=pass1_initial, + boundary_val=boundary_val, + min_duration_samples=min_duration_samples, + ) + + # Pass 2: Recover weaker bursts on residual power not already covered. + # This improves recall in mixed-amplitude captures. + mask = np.ones_like(smoothed_power, dtype=np.float32) + for s, e in pass1_ranges: + mask[max(0, s) : min(len(mask), e)] = 0.0 + residual_power = smoothed_power * mask + + residual_max = float(np.max(residual_power)) + residual_ratio = residual_max / max(noise_floor, 1e-12) + + pass2_ranges: list[tuple[int, int]] = [] + if residual_ratio >= 2.0: + weak_threshold = max(0.3, threshold * 0.7) + weak_trigger = noise_floor + weak_threshold * (residual_max - noise_floor) + weak_boundary = noise_floor + 0.5 * weak_threshold * (residual_max - noise_floor) + weak_indices = np.where(residual_power > weak_trigger)[0] + pass2_initial = _find_ranges(indices=weak_indices, max_gap=group_gap_samples) + pass2_ranges = _expand_and_filter_ranges( + smoothed_power=smoothed_power, + initial_ranges=pass2_initial, + boundary_val=weak_boundary, + min_duration_samples=min_duration_samples, + ) + + # Pass 3: Detect sustained faint bursts via macro-window averaging. + # Targets bursts whose peak power is near the trigger level but whose + # *average* power is consistently elevated above the noise floor — these + # are missed by peak-based detection because only a few short spikes exceed + # the trigger, all too brief to pass the minimum-duration filter. + # + # The mask is applied to power_data *before* convolving so that bright + # burst energy does not bleed through the long window into adjacent regions, + # which would inflate macro_residual_max and push the trigger above the + # faint burst's average power. + macro_window_size = max(window_size * 16, int(sample_rate * 0.02)) + macro_kernel = np.ones(macro_window_size, dtype=np.float64) / macro_window_size + # Expand each annotated range by half the macro window on both sides so that + # the long convolution cannot "see" the leading/trailing edges of already- + # annotated bursts, which would produce spurious short fragments in Pass 3. + macro_expand = macro_window_size * 2 + masked_power_for_macro = power_data.copy() + n = len(masked_power_for_macro) + for s, e in pass1_ranges + pass2_ranges: + masked_power_for_macro[max(0, s - macro_expand) : min(n, e + macro_expand)] = 0.0 + macro_residual = np.convolve(masked_power_for_macro, macro_kernel, mode="same") + + macro_residual_max = float(np.max(macro_residual)) + + pass3_ranges: list[tuple[int, int]] = [] + if macro_residual_max / max(noise_floor, 1e-12) >= 1.3: + macro_trigger = noise_floor + threshold * (macro_residual_max - noise_floor) + macro_boundary = noise_floor + 0.5 * threshold * (macro_residual_max - noise_floor) + macro_indices = np.where(macro_residual > macro_trigger)[0] + macro_initial = _find_ranges(indices=macro_indices, max_gap=group_gap_samples) + pass3_ranges = _expand_and_filter_ranges( + smoothed_power=macro_residual, + initial_ranges=macro_initial, + boundary_val=macro_boundary, + min_duration_samples=min_duration_samples, + ) + + all_ranges = _merge_ranges(pass1_ranges + pass2_ranges + pass3_ranges, max_gap=group_gap_samples) + + for true_start, true_stop in all_ranges: + + # --- 4. SPECTRAL ANALYSIS (Frequency Detection) --- + signal_segment = sample_data[true_start:true_stop] + f_min, f_max = _estimate_spectral_bounds(signal_segment, sample_rate) + + # --- 5. ANNOTATION GENERATION --- + ann_label = label if label is not None else f"{int(threshold*100)}%" + + # Pack metadata for the UI/Downstream processing + comment_data = { + "type": annotation_type, + "generator": "threshold_qualifier", + "params": { + "threshold": threshold, + "window_size": window_size, + }, + } + + anno = Annotation( + sample_start=true_start, + sample_count=true_stop - true_start, + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=ann_label, + comment=json.dumps(comment_data), + detail={"generator": "hysteresis_qualifier"}, + ) + annotations.append(anno) + + # Return a new Recording object including the new annotations + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) diff --git a/src/ria_toolkit_oss/datatypes/recording.py b/src/ria_toolkit_oss/datatypes/recording.py index 1faec21..b282d9d 100644 --- a/src/ria_toolkit_oss/datatypes/recording.py +++ b/src/ria_toolkit_oss/datatypes/recording.py @@ -601,7 +601,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.to_wav() """ - from utils.io.recording import to_wav + from ria_toolkit_oss.io.recording import to_wav return to_wav( recording=self, @@ -651,7 +651,7 @@ class Recording: >>> recording = Recording(data=samples, metadata=metadata) >>> recording.to_blue() """ - from utils.io.recording import to_blue + from ria_toolkit_oss.io.recording import to_blue return to_blue(recording=self, filename=filename, path=path, data_format=data_format, overwrite=overwrite) diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index f162be6..0234e11 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -134,6 +134,27 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording: annotations = list(np.load(f, allow_pickle=True)) except EOFError: annotations = [] + except ModuleNotFoundError: + # File was pickled with utils.data.Annotation — remap to ria_toolkit_oss + import pickle + import sys + import types + import ria_toolkit_oss.datatypes.annotation as _ann_mod + + utils_shim = types.ModuleType("utils") + utils_data = types.ModuleType("utils.data") + utils_data_annotation = types.ModuleType("utils.data.annotation") + utils_data_annotation.Annotation = _ann_mod.Annotation + utils_shim.data = utils_data + utils_data.annotation = utils_data_annotation + sys.modules.setdefault("utils", utils_shim) + sys.modules.setdefault("utils.data", utils_data) + sys.modules.setdefault("utils.data.annotation", utils_data_annotation) + + f.seek(0) + np.load(f, allow_pickle=True) # skip data + np.load(f, allow_pickle=True) # skip metadata + annotations = list(np.load(f, allow_pickle=True)) recording = Recording(data=data, metadata=metadata, annotations=annotations) return recording diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-black-transparent.png index 8da49e8a8d35a0383b86b5d7001463a78eb42178..807e4efe1fb6ae956178725218544c91b14d93e7 100644 GIT binary patch literal 130 zcmWN_!41P83;@7CQ?Nh-7%*|R2^b1eTcSep==9C&q`UHWwEmHG&SPv!J==V|%2;me z8JE=GY8*MK%ZT1sj=F=#jd#k(4y-rPg|#FsR$D+rxKgmrYg>qdu}(XnvSZ}TkqZ;v NkwA?0%aQ=di9f)bCcgjx literal 92294 zcmXtfby!s2_q7Tl4I(+DBFfO+NGTuflL#Rk7Ga%hvqJ)5y#E=6F$dC?= zbTf1fFfX6q_j&)g_j&HQXRo`@xpDShYv0cXdKy&Z0P)?I;Hw{AZr zCAry;*(>JXoVe{}q@jANVvud)=788yMOWq4t*TgxOWV6Q$7Js`&Ao2jV!Hp|b{qZV zQ0dk!Leb0TD#rd++gAZ&KK-wlF8X7o$ekZQ%KD_Y!E;B8n-`HQkxv@+McGM>`;)gN zDJ^A-H=SxCF)h)AN)wLUhT?FW3zF?sP^t4L#`#7oV9Td`M4H?=R_ezjH=FYWUF`RS!Yz`5zjhAkHyR;?I zdXh!>D`F)BFl#n^Xx{1P*6g?RL;CkBM(PB6R{lgeyuij79XfHw#A;nR3`Jrm2y=&( z63N{HZZRHb*YXG&C;Nvvp=(g?9PHUC>v_6>i}^rDF>}%ZRO-4EhrrFB)geNE z?>|EX{lU5k&`Z6j^mmwWSSqLvaa)2(q#rR?Uk#>{)1+P6Twc))`F)pm5pWHGtY4|C zc}mz_QYn?~>#_o^9A0Q#n{mHTeCnObi@{55J`W!)z(!9NxKzHUaS6&m8SWEf|1_-e zF83;p`d5${R;E62C`w&ZsiaX~G+&1XB2lfP`<0RhoZ>~7FhodbJ<87|$cDTBXd1Cp zd+MSfsU<2*)WY$O=rq4R+2JpA=-d7xH0TdrGDge!K#k>!@fl>OHZ!R6~C# zHni0y(0zG@YFKCiz=|##)bsKUflB#tm5r5gz|zUUP@JPww&$wVmjIS^ADd)5RQJdA zCkicgfOzxHv(R5`=Mjj+w?bxp*DRqG9(`Alx&L9-6Rf(o1sCo~r>pc3C@4PbpM1F> zL;UT*@+N%-DcrjvurXQ6s7JGs!`XI0+)d3UGw>|2_*`G|5E1QzYxeD zPpV5)_r-!bc3bsHW$4^`W61P9+@tC33mvORk|4+zM?}PiE)S0S=(^OYt!i$dk+2sVzQQd{`>wbS|x>oeFQ7scKVB+^<*q` z*_7ks+@ny}g1LBG?CT(unb@DNK!;G_XR_z4bSFY)=bM$GflC_FA({o5#xI4haGE46 z7oOMQDBuh61vUn+=2dZkAP?)^eQSvmq*u|dZP9KnG6n+cUx3Q+2+NJ7?98%;=IxsT z@LN{z)}Zb6u0af4;hdwr2hHn)ghT1`+jP>4fc}KY!EZg;J*Mo#gw{`=vdllWwi8@V zp@>rz#4gCRs#Y2bpWmlpE!n&jLU_z-1Ay<<=}ivx7>8Omd0+etef#$z-tz>5(i9KE zqeMw2te=v&8y6T@l0ua4$4k3jRw6vg@AHEOuLBM^7UwAw3={c#;;;MEN%D|$_Ki6I zsW8{&scSjLk@=6OUo|W>Hv(PNY55)yVv`@%L|A{hPQ(49VJ|N0vaDV&klbKZ;63@C zLf7kmWd=pd<>PE-5>DN`=g-P+t%xY}C<-h(E!21(90HvSSz<7_a;0w30ey3OgZWBV zIE=UVY~ZZC#IUUvEn}Bx>fn}(bXZ$1VG`;erdi^go8af0>zt_|j;6zm<+fCx;w-e2 z^Lr-#EmnSN{*iEukWRjTu~zj6B=j=2Y7{{wlMK>H^GWwK%tg-8DoHAwGS7nxw}%N4 z$t67x1D}T~f)n0MTZ>zWcAnbhmoJJO21C2*oo4O;M6;bI$l5tw1jE3*E^efZPNrZr zYxUMg(M;mO@G8M$#uc1J~dKgDE8r4Rp5(F)|szeYm#{S0$QLPhX;5&-|z+%BO-@S{q%|tKtuu`d= zVI{vxyInPch@vA!7cs@%y&Ane&yxmB@UB} zGW~l^E}epLhvV_Q8rFlS?lY!?4UD>~)qXp6p`r8Uc;t^9*He`>IL!NRaKiU6h6G@2 zWQ*%krs}SmwQ}bxg&G3BKS{82%%^jn`( zQ-ho92>5P32c<$==x1LQg`FnAT{{?O_fKZbBJNmimw!$?&#ltt-v^o?n-jbjKp!G{ z4ocMSvZE^RAf`6d$!p#|_v&@gAII4-Jh!gV)^lLPUnb@28lY)HA|zI@FFdd1ciRIUutF>Ao^Iu- zEeZmPyW~#7?IWHi2VcCq1!*x|(Iz^%&#%JPK3V3*rrbSGeqvO>bw%*s0RUdZY~Plr zUO}Bop+eU%ZtN5O&JoX;1GolgyRKC4Q#8v0H2A+K9G)3y zndEN+(ihmx)^j%h_X}BpPknkV&6PO8Xo;EA5;Qnx6ZE3Maa*l&h#qR3eP}#k-A7GHKO0DZYsTC$&+0wl zAI>FLy#MqAmYxyjR$d?VJzOk}+nnGI=>Y?a?NhEBuS4~MpQ`vL^W#$n7ARQpWBkb; z-#frY8*ExO+2~~12)mDKP7Mu0>#tY#V1(V=q~rmH*C4&zDCFb>vh%f4G~0?nE+hXqc=%JPckA_(AOna6?-*-e~R>iRQr{}-vO^SF10qEkH zD2{ALIv!a&>yxjpx(8!xKLB`7xzU%tF+~Q^WQkC6SM+4(agd()5zcwLe?JLf*Bjb6 zyx1vp_g{SKy>Iv#cZn%OfpNua`SA+5BC$*Rf+FW(mfzfzrQ2R}lB|5C3%p!B!S(8j==T=LpeDg|n=Gi}GzXR9GO@Ye`G` zd`}~5NzTvHfLB1h7+&|==sCIO{=j-jYZY?$>m)c&XlCM9Bxu#Y=nPV$h$csPDWrz_+5rO z`3*t+2Tv(7^6Ee3jB7sZ@h?<(sn!VjX7=iH1CP=J;Qny_5&uxi{CBklEfEx#yJN29 z)qEz%J`202v$4wN1aSy?dv~Y5#yP~=Rw^(|Iw{kW=7`FX;#T*T5*Ot58wdELuW!ZD zGr$x^vg~w)w@85oo;yr3DJplW4COIbi#}^{(1wXwo`zDSlgid>l0-EWc}r#t7m6XU zyYiS-v-N%htpJWAEg;?%>Zv*Jl=$P68~IyQJ|PO)moIlGB1hd8Xlbw>b;sW-G-su! zrNth6gcH8Gj0rzvTY0-)>bainrBZdw!M{SaotmJ;grpr@PLqWv{}E2;rWYd+Qu*G* zHIUzVqFDLrr(k)us*riDhY|$~rmd6;(xXh6GeRHQ#2KN&#fqr91*}64nd#!w6l!&C znAAfPwM876O*y`d%}0ABnB*a$#^_ zzIa(udXm|)giXEizBzksw{hB{`7$lGALSQ|EICF{xPwKOVhEvTOEL>pCp%Eryj%MM zkH5WHuguE#In(i1yx39$ibL2+o4rJi)3)O9a$c5eq+JcZI#K(zk-)}dtnNM@;D_)` zS`BB!26xsrh}`AT72__T(y9rHRozizBVM@WJb4k!snH zpvzg4Pi1T^dR?OLuY%7OQJl=z5jpcRn?3r{Q|W@;q7^lxwMs+Qvev`4id;f$NH^N2 zjVMBa%u1s2+Q);g!&Hx?-758mVIbMeE6J-5WKzCl)>U=MFLe*f?2fUxDdll%4Kjho zgW==A>Y^d^SWZfedd7CzzI@SkrL?M{X9jv(3C9|em~tQU_PLg$nRhwi`+UQp=rp?s zA0)Je%85}wFaUv}WD-$kthibD0VqV~CA&4`Ls2YGj7RT;0}BD_FqiYVj`8Z}Muu`h zc=Y_=Jy`_6rprI;oeAkGLq`J3#WaB>>Cb}uP+MtC z_JQ`&?*^$KptgC~N}EUTjt`g9x)jnNQpYL>%Z!!d@|BQKj_~+1_<68~bxF0Q-jtcO z8&9YY_l)*gs&YJSsSTZf?I6_dPo?CJeWyfi9?WtR9GJNMq6V#J1y3V-P|@=rtdf4`SUDX_!a7Q)t|qCPaGAc7D4Ri}0Abz@!1Hos%2 z;tHUAf?mCQ3q;j3{|jG1O}RNfpAak_RA==2YmVmn!VpM7^&}s}w_;YM*1NVbOki9~ z$Ek(thuTYY%?Ag&J4@u}D4b|`s9my`Jj=QX*1GSpKUKoFUG!XCF{uR>Nw{II(luwo zCKr3%ie@51n8gc@{;3U9jKZZ=C6>Mhtm({~+FdF#uU|`X$jlS|TCBcV$8AJ5v<@$J z*&+WxaQS;1_!y4Rp6h`F%9i$jrx6(0l$Q9^?Q_@7_n>hV2&!(3Dd2fuU~CdDt)@b z@?V^=APcJ~t`1|qCk`Rqj>`Y$o`MS^i4q70$a!4IX+Ql=h0;ZVQsDW#M)P0SmcRZx zXIClL^=e569E?}D(iKh+bA-Ce%*wA=5;(J@Ww_CaxH^lH7w75_|Gg8O9KM;;;m@ws z^{MhDp;tq3)6lOXwHXDtu1Xy7TEcbMFM3ZtJo8%5L>Z8*&o};uW6Hhan`?FwBU|`9 z@qUFW5U<`QYkO}!X%gs=eONR*FX2Qeo70d>{5X*Je2C!t&FBJ3$Oycc{x6Vfs?2O@ zE{k$Hx{1anmiQ9sj*9(rmG?8v8_-{TP{B(t8{+C&-USozudgkaDrg7uw8*ZYUXCSR zLrQ+uvKj#p+f>O0KoMKHnOSS_yFU#Sbgz6VHN)CY{dbJg6}DgVOsN7Dl|t~pP?*2;f32Tf;wvKJTO zN=Ccr671%_*-x!3l9nqzC2U_F!ly)gu~l!ZHFQSfyj_`-f9zJ#Z`^`+T}ytDxbupU z(^rH#;U|4YBU_J=79_D@#2O1~T&<|nRb4PAr+N~X=qUpySOAKwCVlzUOAl-r7<6!n z-kZGbq7U;TUCBPZVo89!YLh>!jrbG%fU49w`K6+xo*0RyQ7VkL<@ABSuUmehb@k9^ z?jVV~D-wnlMuQxdDfRA;r>of1`9h^%LX3;i3po1GAZ*-dgLf? z%pbe{>E#qmFxKkZrv;T4=KLMYCI0Qk`bVs{^~~WM*%(eyf$D5POgCb|*_8VpX(wKU zA4GzAW29%m;e%aD0!7k)PmAx4;$55B66dYg%V!S^aAx()ku@09Y}Ci{r{wm`cE@VOY}V}A0o zwD@Sbf-)+C@IM|I2ej4bo@(AZNt>T1?6U5XsEf)Zy8f_gjBbJ85AzqIS&TLIZiJ&}NL};n8=YBI0&uxu`R}Y|Dz?9=oyXEsc;s zZf^eeq0+m>o0J7h5cYUpmAL)dZ=K?T^fwk=`ce++yAJ?@gJxeB_C@XS3Yt%Y`kwgx z&Vc(wOBJujgI4rkH+(+h5gTdm2w~3c9eU3I_XX0EYjh+^Z$$Uk`R!el>EOiU7gJ1H ziY`TBD1Ydcfnkxb6qN9a>y+SqvQ1kvsCe(g1P08^;3u)v@M+?c_lSj{yRmjpi;IWs zB8&S@1W?}cP1HM;UP14}SxCg{BzJzV(OyTYtAB9xVWkQSboBJF3UAlUU?i`<4T=eS zmt79en7l_JJ{j#F+1zIqm;E~??pN=Unbcjc9Xg^7R~^0NgM9#NWcl|3VNM9vF|nsl zdA>MkKrA`DWAe!>i=$r58(*yb;d0x>8>=T(G*B5!^qQ z(R;?V0O^_P0E_9QZN?(IrF??2%e#E$KlEi!kDB^s^?Ho9{P{mnncr0da4t3H`;qq_ zaAt~yC@6DEMKM2t3N=w93*$b}cm5KOECB%3wVRc(d^Mitz!Xeci|~5kgS@beP3Pn) z0s38(Ap+t+ywe;%&`_r@1Nv#E9N@%OENLaZ}erCJHuWcLH0`u)xs^11R%g}&`8 zr>dpmCv_?RXFCjENo3+x2MYF@`%g-?T5^mY0>J?K&@tz%)&Mu05Zr+PjLt?seDnEU z5al;kzDRWyevf@<)j{*1f$NkG_2FfUgMj7_I$FA&su@sUX&+R`*|YF zRGxzzV*{>kefP7<_}_k>NlS329EY2&WAl?%I8XMs_cy6S4`7-`Xik%>(}F-(^z1vAw8H?ZPXZwTa$LcWvfxds)}#IG|#Jj zeMWw*IR-AhIf&|k?Qu%VZToJxp=%@8Q$`zPiyJveLX^0eNkB1#QDC0hZ1;n}p*PPFIe%|JUs9*8qg zYu>FP+T8z%+D?l!+Lkn`PPU7wGCpCFbikJ9bvFn)kf+7Ct7N^Jfc&vZ>>G9KZ{xgx zFG;c>TMa`e{fwWT1%Fp!*P>Px7!hhW#)}wJIvnMdPY=t5eSm5?T)*{@Y4W4$ysd*Y zk3+m})@pL)O+$xjfeB4x9P_ui{8iHd=We$2ISREJva09)z2+=Qv3D@Q`I(riyX@LM z+-CV>P4J@d`fce;xVrm_1!v+sToqpcb65GZOL4McuU+LjWGF-tIVZf;-s92Ry3!J9}4>HZZvkd9PZT7iysP$qs=$$pa%aZuEbaSHIgXX_4Q5VsuiBW&)ENHj?ITNf zUA-l*kGSE6$BYt1(y^{%Rm{&KhRbXQ>BZ21wEL8P79lqmpV5uqN?dkQ``vQH-#>s0M|Nd165o#Xs zR{f>c*Q*$#FjBt7w6zE^d-B76ghD~-MjR4xnwd_SE;IQ|O#&hS1FUbic}5+KG!)bo z|D5y+Ej4U_=)v@}axt_l`}L_)2}u{a!J8Qm=T=r!o?xt5uID?7!hRgz%T9;&Itvva zZPfs%JnaFZziJQO<#_7;5$X+^za=(~fra=L+zyLGjY{@n*cIAqNp`j;y|U7kSBV!l z?{cUPw0?fTk$2CIkg;(Ko3W+e0H`7;e#`UGM0f*jW@@TY5EeCYh#Z%D_v6BXL$4@%?~qGvzs;* z!hu(d#|b=P1*7^&P@qB&*HJHOKWonMYr0q}9?qjb;>J~{QYk1d`z+5Rs8t3pRN~NO zc+>uVcK$WbxFb5gN}_2SR(jj>dndx2!9uOcAv!2$!but1D6Z3&kQX$MdV_*c zSBKX*{Qwu&=^hhRxe$34TZ7yqJDzk9yDt6ZF3tp?{Bt^KCuYp>zl(Ycvp(6LAog@(wqsIA zA(CjtTT4t?#&`FYOj+X1+0q_7Nsk6LQus0F{#tp))@{b|rBvN77b?)s`C~Z6#?-M^ zMl{e3M0G+w_`(;8A{>TZW3uFT1H<=qQhy^-l2OO489QE|qX-k{2YtLWnP`PDh z^|nn$MoT0U+h0h$`l*)W!mlH#j5K3F>>!PVtG95eHKvc6RwF~tB5{|2`ZH_KP6N7q zPJd%tb}x^U{IjqDS*8Jhm8*(|DX7_MyiU1twiTP?1`{tax^q9-ozM!4sRt745X>{_ z@WM}1>R~tUh`UVMfjV{(1<+3YKEo+>Aaq(TR}to_`eNv8cG=Hxf0I>ke2Sn@ zHcNt7GV6!0ycQ-6IZkk{3zH7x;q5nxb1vq>1En!nN9xfAAka^sZBByRNX6M453Bad zA2H!Vuf9piW3L>Q&BU?6Bc9PeV6>MOa=q|{SWRtNbB}$Qx&7}~C{H!oMEWmf?|CcT z`9B1b*qY5|1u{W0=zbeB@Yjz#D@H$0Z8b@o4HW`hfzhc+=kbUl0`_4IJ`viVx@jU) ztT2lgL@d>CTl;7))!ap!y`}7!f`}~n|B_A0BBJ75(;x9YX@%DfnX_kun@fm8sL5H( zd}hXnQrrYoV8BpcsN2JV=5()xiG$1$;ihhTVK%Q9K|r(_$8F-`I9+H>$D`Poxase0 zpQOjhVCstkAGS#G04xp^ZCX-T?md!m;?CaU4JI@CGsfLyJyMl)Eyy6dwX7fqnK{h{ z_b0#;xncLnt=*kJ{i{MO!6=RJ7iuj1+^XSSc_H6K`MnLq8aC28D(~09nxDda^${-k zm65^qbH;BWzeQoYRef#RYpFFk=Kwg*w@7Ed6UyaRapw=MR*hM9lpioj)a3d#p##VR z0+j-s*S6m@>r_uU4xFf%*R1t0f}$gu;6u8i$&8xEMY0$fAgMmLE2+w5*&cV!MDJE% z@9ub&F=aslgyOE(**%179r%S}u|ht0PX%$(iGIQrD;u#ypT*kC4Z~Q1$u_y|x!w6; zjDTFqPxRK4eu|^e?8dFj=km~PI#m zZ($0)Vmy8K7y{Yd=}(b7LB>XRKfV1hurviU<`%kV^2^Tc;io3|=_f?8R$x~~)1aJ> z@LUmiLwFI+nJuU3ZugUbXYiP_-FPvpgeuA*in-P-rAcubo`N=4@Z*-wrd8kfpgwVB z2NF;ABwNf}FfZrix2ypZ=J`f&2$!p)p#ri-+iBd$Nmw635ucAS!=wNgN$<3Hc{O#vEE&! zgYN%i!mAQl8|s#LS)uxlA{6I6h)Ma?uX(n#>q+6htx&#z7%usXAF)vu=}#P2 zQq^)k{nc&Rfs5t7q!+O>$L#E8#7-TQFMuEf)kQ3mW}1d0Z321jfEG2 zB&D=P_FTMNU|WJX%?n#|zb5wWZ&jN+P`SrEk7P)+HAt6I+vg>VH%Ktad(5&C)Y@4~DJ>JAa+HepJ+IGJBBB1r+`)M-& zkL-d!zfv-laVlZ+jFZ&Mx#IFa!}CB)J9=-IHDc_)EW7UWQ@=%AxR_#Oo)@<$jO$PR z6;l08NGb_ozRYhK=jJBoXXMHNLdczujlg}sI=hQN~+xi&4HW4mD zLr?K^P8tWJN|RK99obzUkoGH-BmR*W4OUrt!>D^|4@>+v4AiN<0KN=`T@$`i2U*+~ zV_ykig?!Hj9jrqe$xf@h_bL7F3=_yxkzq7lK7vMU7PaTII*K|>g^%XkFw2q>B&uLvU4^( z9ghAQJrb;xcm^~4uPqwP+?U&25zLv+_g{v0kLU!3NXwhQc_ ziB&1T7OdA9Fd8(?GvKvkXMRamo%V3x>;yDQM;o_BUGF`guJ45@;S`a>@q zJmg^xFLag?9?3SsTLi>ZrN}$|_}j@^v-VF|;&<$Vz|FvZ+@is~K%^O}_1Kx0?Psxi zXsVB_dXKK}(OsRIBPOp{iYU@5ayD;;{pT+!<#@&TF2?0@UA?3;_3hzfmWDm96oEU( z_WrTw=Cl8C0F-#jA}?YVNzkrL%J9(zEjP=N<&Pyw=znt~kv*TvA_gD*R&*tEC^SeX zF*{h1lMigp?2nYt5~Mryejg!zAwVwL8h(@aSNS_FC3+-KKW5N;W*=1lF)uSAbN83m zzV7FBy{EoEe{^|O4a#g|+cT+I$}iD32E(Zb)Jp{#bm%NvjvO_cq3SKzg-Ogragyg# z8~Hags+aNEj645=M*tuev}&* zMZyQ?CDg46WAqJ!_hQ=U;5ACxA>keO^THq`G~K+x_)k>{gx-d7G9Hmyln0VZQdj z)&6YR;DGW?52*t-#G;^~ka~-j^7BS>=N>tY?o;w~l=u{}rv+j)vC7+kY;xM<^$mi3 z@IzmkYNZU@?@NlchBp}A>vy$p3Or82qIF-7`x+StT)R0x)@ZfmE(l;%{8K75N_+>*I!P^w0u|XVGh zmzkcAj8*nd1awSB@@n<|!F3m}Zp+f|H%Q+cZN>^*ju$DuQ$=^g@SHTkf&gs-HZ8rP?Qqx4?YKrmV|?kM zEsTM+>w~82R5V*N{wZl1m@$6Rfbmd)BD#tZEliv7cEgQ$!?@ z>9=uf9vt8|hsixO3;ij|A31(R3Y>YI`2QK3lXtL|fDeoYb`V%|@u>n!PU%MdjP}zk zdFC^E549BtZB2FV?f2~tHZiv`Ji^D6u?p8axRCjaSqtWVUQ?43-c#A1KP=aT{-a## z1+04?Evqns<=xL)%V}3Or$rketbta4nL5liDSpf+1-~*>ZP-zPXMBNkGClaQ{OXJ1 z>)ic=>(lIbG{LAKOA>HAVZc~rV3ARM&^szY+Tg(NU-ElLo_4trp-Kx7AjuO|X%)dV1>)x+9ar>OO!DkkL&l{7} zrl$O6AD-is~Fk8TXIEAX9l96Pbq z%8qLheKvjEw7l_ddt#`HIC9gJjl&}A$9nv^FWzpbN{z_0PX5iNe7Hor`l!FQr+bN)>=z=g zc|rNQ@x=Y}8|}PQ$sSMLzL!%LUHi5bOm3`|Ct0I3F-6wGPR4VRL{Tu*+N7~vr^eC4 z%-3db)UHMcT(P%mR>*^phkTmz3+pO78*GNX!upW)ipq{nmdd<$awk;U0n=edoGzxy?aQL=YjY`h_Dfy- zRiGx)|Lsw|Ark$6cX#F3*;~2 z4x)-)!ZC(Q7{m3JaGJvm??0G}<#Q5k?!}|3;7YUK3jU;h(H}oF3$iL0l*D#sx@c?h z(fD4<3&$TgX`{I-%9dDO)uz!>(xfou6S?&7v#5MQj;ZDF2~X9b{l#a{6_gzhFyl$XWqEq7mP811FvzQ>e+r9Pn^k*~rn*N8bZ*E8*fvLW(Egg|oja39 z89$33$B-S1ytZE%8zyu9{?Nv>=h>40kX!_6I+%h?d0yI8T4xhMClF&58dYL_o zkp~c-L|}5s?yn!so@&mtBPL?dybA409Ys=ApBpCVf#D zmair7BJ-iMQ|4_QXBJgdG@IDf0Ri6nq9zo3bMfxavQV_hT}-s&NXoXXD!l{q^5XBf zeK@@kGv`?&0+3mB414mp;0eTFc-yZ~MqLE~drN!kHe{tRD&h95eDAJ2L%+U_vb3h0}7B+ zGOcP8?;~r3-M$`Eu`!7({wOkOVv#_7w&en0CQL(+rx=QKGEsHPm;tK%gt0zp~B8P>mS#u&C-fJ_0OQv>n*%kOipAAWT zyhUc#uFyc3b+Vr-wj-Bhcv_8jeEGerDo?c9b2D>+$FbMmTWfO$PC@ijf1&!O#^9HQ z8@$AcYc#^^-?`j?@%HE~wDOKC4)|gfv2IVv6T3~@Vrb$sYF^`)rVHOt;x0x{CMftQ zkAkD0$sHPnq4IjO%Q?l`L|#-z$uDaoBpsqRfiYxTTk7GlAj+U&VA597LTdIWN zXWIS0?@)^+Cqja_>Xkm$@$Ho&u5j~16R>Qhu?a#<@c|%R)#aPEvWcm^f}N=Arwhj$ z;&<8o-4suMFysoQzOOCHig}&idmFoDJbC!%aq;p5_f2NX1etQ!ELU^O6Gwj+fKQw~ zv*+>+-~+J>oiQMm`>LWoI3g5k94TJBQ)QOUo*Y?oFyIW9v(Twpx694&WVXqvqKu8V zTpRc###`_~3>GqQYmG8rLo_+9o`EcQ`5jr0k!zdV18INxAV@(OpR&k73*BpdPv z%NjoAQ7$-*DuwCk%H|dO->`DVJrwC9&Ivrx>MUYe?Hbar1uQ`7`_IC*TB)1p=Smbt zR8fBE>^qx5H|~AQ=^{UU9pGS#kU21DR=Sul$;E&uUS0&rXtLaVuBLS%g1j~DZ(~&Y z|ILCH#GLIq?t2|A*`r2?5Y%}`*Ztt1>)b=%JIy5#r7CsvhZF!^T{xX<;yQCGU9NwE ze^g*hZ-o-mX+X+y=4_+_gYEqtbwg_B2cDP7sC{;&(O%ctB#=CWZ4rLkYU}TXsuju3 ze#?l9-PeIR*&^ZI=y)T^rzf&^Pwg>MNmTl=AEp2b|DaH|`Dy5z!JqwRU)}&@{3l$g zAT}-{H_IQJ2R*pdj`(qu0Mh_pVQ6&1^E~=FCtJBNIXBLXn{}o%Sxx5AZU9*-9T&~R zk;uU9O@XysV9dmd@xAoTASWr>45G0uT7QhO!|kwE{))ue&OPQRKc6wRJTH6P-}Vv* zrKYL*BYB0Y+Jnr%knJzB%acw2(k6{frxGUn$Nq^z1O(rG237p2WfzsqSaN4IsA2gR z1)Sut7f?)GJd_dG--0JfnanA#TxAS*iNyayVw~A1j%A*yF(ftk4Ip*u= z-3$u7o-;RAVrCAGEzxFl+t|P195E?7v>9r{-iyil#5N_{{z{^AO#Hv+KjBehTKB51 z(fh_EU*xN%Dg)#w^AFRW&0E@{Jj&dS<4qob!GDg}lIGJETrlvBWj=&PhRZngUAB zk@h5wvi^?7zng#_Uz@}1cCrfumtIwOO8o5*54lkiVpCzu?zm3lX?A-7n(jop{IcNo z<#M_8ywuGy;%}WtcZ@5yr%V?(2}_}u6+nffJQ`ixWH;doNjS}F!fAyge z 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 >x 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 diff --git a/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png b/src/ria_toolkit_oss/view/graphics/Qoherent-logo-white-transparent.png index a18426e743c09368b889c16eb3b72dd30ea8056f..185600c26a5c15b58d9479110ab44ef9308e4c93 100644 GIT binary patch literal 130 zcmWN_K@Ni;5CFhCuiyg}SYbtfv%qd4X^Sc9p|7u}N#^43X#Jz>aqMF==Gl(NOU86t z&ot%!He=^(E~k1cdQ^gd8|26|y9Ing6fWSigh-ZxC3}~*5YyrySQLyi1-8Cm@NQ)R N5mKZ5Si3a>sz2I3CoKQ~ literal 19826 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 V7Gv3e865xs002ovPDHLkV1hF_$2b50 diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index f8d5731..3632ea1 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -4,6 +4,7 @@ import textwrap from typing import Optional import matplotlib.pyplot as plt +from matplotlib.patches import Patch import numpy as np from matplotlib import gridspec from PIL import Image @@ -39,6 +40,83 @@ def set_spines(ax, spines): ax.spines["left"].set_visible(False) +def view_annotations( + recording: Recording, + channel: Optional[int] = 0, + output_path: Optional[str] = "images/annotations.png", + title: Optional[str] = "Annotated Spectrogram", + dpi: Optional[int] = 300, + title_fontsize: Optional[int] = 15, + dark: Optional[bool] = True, +) -> None: + # 1. Setup Plotting Environment + plt.close("all") + if dark: + plt.style.use("dark_background") + else: + plt.style.use("default") + + fig, ax = plt.subplots(figsize=(12, 8)) + + complex_signal = recording.data[channel] + sample_rate, center_frequency, _ = extract_metadata_fields(recording.metadata) + annotations = recording.annotations + + # 2. Setup Color Mapping + available_colors = [ + COLORS.get("magenta", "magenta"), + COLORS.get("accent", "cyan"), + COLORS.get("light", "white"), + "lime", + ] + + palette = ["#2196F3", "#9C27B0", "#64B5F6", "#7B1FA2", "#5C6BC0", "#CE93D8", "#1565C0", "#7C4DFF"] + unique_labels = sorted(list(set(ann.label for ann in annotations if ann.label))) + label_to_color = {label: palette[i % len(palette)] for i, label in enumerate(unique_labels)} + + # 3. Generate Spectrogram + Pxx, freqs, times, im = ax.specgram( + complex_signal, NFFT=256, Fs=sample_rate, Fc=center_frequency, noverlap=128, cmap="twilight" + ) + + # 4. Draw Annotations (highest threshold % first so lower % renders on top) + def _threshold_sort_key(ann): + try: + return int(ann.label.rstrip("%")) + except (ValueError, AttributeError): + return 0 + + for annotation in sorted(annotations, key=_threshold_sort_key, reverse=True): + t_start = annotation.sample_start / sample_rate + t_width = annotation.sample_count / sample_rate + f_start = annotation.freq_lower_edge + f_height = annotation.freq_upper_edge - annotation.freq_lower_edge + + ann_color = label_to_color.get(annotation.label, "gray") + + rect = plt.Rectangle( + (t_start, f_start), t_width, f_height, linewidth=1.5, edgecolor=ann_color, facecolor="none", alpha=0.8 + ) + ax.add_patch(rect) + + if unique_labels: + legend_elements = [ + Patch(facecolor=label_to_color[label], alpha=0.3, edgecolor=label_to_color[label], label=label) + for label in unique_labels + ] + ax.legend(handles=legend_elements, loc="upper right", framealpha=0.2) + + ax.set_title(title, fontsize=title_fontsize, pad=20) + ax.set_xlabel("Time (s)", fontsize=12) + ax.set_ylabel("Frequency (MHz)", fontsize=12) + ax.grid(alpha=0.1) + + output_path, _ = set_path(output_path=output_path) + plt.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + print(f"Professional annotation plot saved to {output_path}") + + def view_channels( recording: Recording, output_path: Optional[str] = "images/signal.png", diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py new file mode 100644 index 0000000..cdfabb5 --- /dev/null +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -0,0 +1,820 @@ +"""Annotate command - Automatic detection and manual annotation management.""" + +import json +from pathlib import Path + +import click + +from ria_toolkit_oss.annotations import ( + annotate_with_cusum, + detect_signals_energy, + split_recording_annotations, + threshold_qualifier, +) +from ria_toolkit_oss.datatypes import Annotation +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav +from ria_toolkit_oss_cli.ria_toolkit_oss.common import format_frequency, format_sample_count + + +def normalize_sigmf_path(filepath): + """Normalize SigMF path to base name without extension.""" + path = Path(filepath) + + # Handle .sigmf-data, .sigmf-meta, or .sigmf + if ".sigmf" in path.suffix: + # Remove the suffix to get base name + return path.with_suffix("") + else: + return path + + +def detect_input_format(filepath): + """Detect file format from extension.""" + path = Path(filepath) + ext = path.suffix.lower() + + if ext in [".sigmf-data", ".sigmf-meta"]: + return "sigmf" + elif path.name.endswith(".sigmf"): + return "sigmf" + elif ext == ".npy": + return "npy" + elif ext == ".wav": + return "wav" + elif ext == ".blue": + return "blue" + else: + raise click.ClickException(f"Unknown format for '{filepath}'. Supported: .sigmf, .npy, .wav, .blue") + + +def determine_output_path(input_path, output_path, fmt, quiet, overwrite): + input_path = Path(input_path) + input_is_annotated = input_path.stem.endswith("_annotated") + + if output_path: + target = Path(output_path) + elif overwrite and input_is_annotated: + # Write back in-place only when the input is already an _annotated file + target = input_path + else: + target = input_path.with_name(f"{input_path.stem}_annotated{input_path.suffix}") + + if fmt == "sigmf": + final_path = normalize_sigmf_path(target) + if not quiet: + click.echo(f"Saving SigMF metadata to: {final_path}") + else: + final_path = target + if not quiet: + click.echo(f"Saving to: {final_path}") + + # Always allow writing to _annotated files; guard against overwriting originals + target_is_annotated = final_path.stem.endswith("_annotated") + if final_path.exists() and not target_is_annotated and final_path != input_path: + click.echo(f"Error: {final_path} is not an annotated file and cannot be overwritten.", err=True) + return None + + return final_path + + +def save_recording_auto(recording, output_path, input_path, quiet=False, overwrite=False): + """Save recording, auto-detecting format from extension. + + For SigMF: Only overwrites metadata file, data file is unchanged + For other formats: Creates _annotated copy by default, unless overwrite=True + """ + input_path = Path(input_path) + fmt = detect_input_format(input_path) + + # Determine output path + output_path = determine_output_path( + input_path=input_path, output_path=output_path, fmt=fmt, quiet=quiet, overwrite=overwrite + ) + + if fmt == "sigmf": + # Normalize path for SigMF + base_path = output_path + stem = base_path.name + parent = base_path.parent + + # For SigMF: only save metadata, copy data if needed + meta_path = parent / f"{stem}.sigmf-meta" + data_path = parent / f"{stem}.sigmf-data" + + # If output is different from input, copy data file + input_base = normalize_sigmf_path(input_path) + if input_base != base_path: + import shutil + + # Construct input data path correctly + # input_base is like /path/to/recording or /path/to/recording.sigmf + # We need /path/to/recording.sigmf-data + if str(input_base).endswith(".sigmf"): + input_data = Path(str(input_base).replace(".sigmf", ".sigmf-data")) + else: + input_data = input_base.parent / f"{input_base.name}.sigmf-data" + if not quiet: + click.echo(f" Copying: {data_path}") + shutil.copy2(input_data, data_path) + + # Always save metadata (this is the whole point) + to_sigmf(recording, filename=stem, path=parent, overwrite=True) + + if not quiet: + click.echo(f" Updated: {meta_path}") + if input_base != base_path: + click.echo(f" Created: {data_path}") + + elif fmt == "npy": + to_npy(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + elif fmt == "wav": + to_wav(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + elif fmt == "blue": + to_blue(recording, filename=output_path.stem, path=output_path.parent, overwrite=True) + if not quiet: + click.echo(f" Created: {output_path}") + + +def determine_frequency_bounds(recording: Recording, freq_lower, freq_upper): + # Handle frequency bounds + if (freq_lower is None) != (freq_upper is None): + raise click.ClickException("Must specify both --freq-lower and --freq-upper, or neither") + + if freq_lower is None: + # Default to full bandwidth + sample_rate = recording.metadata.get("sample_rate", 1) + center_freq = recording.metadata.get("center_frequency", 0) + freq_lower = center_freq - (sample_rate / 2) + freq_upper = center_freq + (sample_rate / 2) + freq_default = True + else: + freq_default = False + if freq_lower >= freq_upper: + raise click.ClickException( + f"Invalid frequency range: lower ({format_frequency(freq_lower)}) " + f"must be < upper ({format_frequency(freq_upper)})" + ) + + return freq_lower, freq_upper, freq_default + + +def get_indices_list(indices, recording: Recording): + if indices: + try: + indices_list = [int(idx.strip()) for idx in indices.split(",")] + # Validate indices + for idx in indices_list: + if idx < 0 or idx >= len(recording.annotations): + raise click.ClickException( + f"Invalid index {idx}. Recording has {len(recording.annotations)} annotation(s)" + ) + except ValueError as e: + raise click.ClickException(f"Invalid indices format. Expected comma-separated integers: {e}") + + return indices_list + else: + return None + + +# ============================================================================ +# Main command group +# ============================================================================ + + +@click.group() +def annotate(): + """Manage and auto-detect annotations on RF recordings. + + \b + MANUAL MANAGEMENT: + list - List all current annotations + add - Manually add a specific annotation + remove - Delete an annotation by its index + clear - Remove all annotations from the recording + + \b + DETECTION & SEPARATION: + energy - Auto-detect using energy-based thresholding + cusum - Auto-detect segments using signal state changes + threshold - Auto-detect samples above magnitude percentage + separate - Auto-detect parallel frequency-offset signals, split into sub-bands + + \b + File Path Handling: + - SigMF files: Pass .sigmf-data, .sigmf-meta, or base name + - Other formats: .npy, .wav, .blue files + + \b + Output Behavior: + - SigMF: Updates .sigmf-meta only (data unchanged), in-place + - Other: Creates _annotated copy unless --overwrite specified + """ + pass + + +# ============================================================================ +# List subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--verbose", is_flag=True, help="Show detailed annotation info") +def list(input, verbose): + """List all annotations in a recording. + + \b + Examples: + ria annotate list recording.sigmf-data + ria annotate list signal.npy --verbose + """ + try: + recording = load_recording(input) + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if len(recording.annotations) == 0: + click.echo(f"No annotations in {Path(input).name}") + return + + click.echo(f"\nAnnotations in {Path(input).name}:") + for i, ann in enumerate(recording.annotations): + # Parse type from comment JSON + try: + comment_data = json.loads(ann.comment) + ann_type = comment_data.get("type", "unknown") + user_comment = comment_data.get("user_comment", "") + except (json.JSONDecodeError, TypeError): + ann_type = "unknown" + user_comment = ann.comment or "" + + # Basic info + freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}" + click.echo( + f" [{i}] Samples {format_sample_count(ann.sample_start)}-" + f"{format_sample_count(ann.sample_start + ann.sample_count)}: {ann.label}" + ) + click.echo(f" Type: {ann_type}") + + if verbose: + if user_comment: + click.echo(f" Comment: {user_comment}") + click.echo(f" Frequency: {freq_range}") + if ann.detail: + click.echo(f" Detail: {ann.detail}") + + click.echo(f"\nTotal: {len(recording.annotations)} annotation(s)") + + +# ============================================================================ +# Add subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--start", type=int, required=True, help="Start sample index") +@click.option("--count", type=int, required=True, help="Sample count") +@click.option("--label", type=str, required=True, help="Annotation label") +@click.option("--freq-lower", type=float, help="Lower frequency edge (Hz)") +@click.option("--freq-upper", type=float, help="Upper frequency edge (Hz)") +@click.option("--comment", type=str, help="Human-readable comment") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_type, output, overwrite, quiet): + """Add a manual annotation. + + \b + Examples: + ria annotate add file.npy --start 1000 --count 500 --label wifi + ria annotate add signal.sigmf-data --start 0 --count 1000 --label burst --comment "Strong signal" + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + # Validate sample range + n_samples = len(recording.data[0]) + if start < 0: + raise click.ClickException(f"--start must be >= 0, got {start}") + if count <= 0: + raise click.ClickException(f"--count must be > 0, got {count}") + if start + count > n_samples: + raise click.ClickException( + f"Invalid annotation range:\n" + f" Start: {start:,}\n" + f" Count: {count:,}\n" + f" End: {start + count:,}\n" + f"Recording only has {n_samples:,} samples" + ) + + # Handle frequency bounds + freq_lower, freq_upper, freq_default = determine_frequency_bounds( + recording=recording, freq_lower=freq_lower, freq_upper=freq_upper + ) + + # Build comment JSON + comment_data = {"type": annotation_type} + if comment: + comment_data["user_comment"] = comment + + # Create annotation + ann = Annotation( + sample_start=start, + sample_count=count, + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={}, + ) + + recording._annotations.append(ann) + + if not quiet: + click.echo("\nAdding annotation:") + click.echo(f" Start: {format_sample_count(start)}") + click.echo(f" Count: {format_sample_count(count)} samples") + freq_str = ( + "full bandwidth" if freq_default else f"{format_frequency(freq_lower)} - {format_frequency(freq_upper)}" + ) + click.echo(f" Frequency: {freq_str}") + click.echo(f" Label: {label}") + click.echo(f" Type: {annotation_type}") + if comment: + click.echo(f" Comment: {comment}") + + try: + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Remove subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.argument("index", type=int) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def remove(input, index, output, overwrite, quiet): + """Remove annotation by index. + + Use 'ria annotate list' to see annotation indices. + + \b + Examples: + ria annotate remove signal.sigmf-data 2 + ria annotate remove file.npy 0 + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if index < 0 or index >= len(recording.annotations): + raise click.ClickException( + f"Cannot remove annotation at index {index}\n" + f"Recording has {len(recording.annotations)} annotation(s) (indices 0-{len(recording.annotations)-1})" + ) + + removed_ann = recording.annotations[index] + recording._annotations.pop(index) + + if not quiet: + click.echo(f"\nRemoving annotation [{index}]:") + click.echo( + f" Removed: samples {format_sample_count(removed_ann.sample_start)}-" + f"{format_sample_count(removed_ann.sample_start + removed_ann.sample_count)} ({removed_ann.label})" + ) + + try: + save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Clear subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 175}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--force", is_flag=True, help="Skip confirmation") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def clear(input, output, overwrite, force, quiet): + """Clear all annotations. + + \b + Examples: + ria annotate clear signal.sigmf-data + ria annotate clear file.npy --force + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + count_before = len(recording.annotations) + + if count_before == 0: + if not quiet: + click.echo("No annotations to clear") + return + + # Confirm unless --force + if not force and not quiet: + click.echo(f"\nWarning: This will remove all {count_before} annotation(s)") + click.confirm("Continue?", abort=True) + + recording._annotations = [] + + if not quiet: + click.echo(f"\nCleared {count_before} annotation(s)") + + recording._annotations = [] + + try: + save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Failed to save: {e}") + + +# ============================================================================ +# Energy detection subcommand +# ============================================================================ + + +@annotate.command(context_settings={"max_content_width": 200}) +@click.argument("input", type=click.Path(exists=True)) +@click.option("--label", type=str, default="signal", help="Annotation label") +@click.option("--threshold", type=float, default=1.2, help="Threshold multiplier above noise floor") +@click.option("--segments", type=int, default=10, help="Number of segments for noise estimation") +@click.option("--window-size", type=int, default=200, help="Smoothing window size") +@click.option("--min-distance", type=int, default=5000, help="Min distance between detections") +@click.option( + "--freq-method", + type=click.Choice(["nbw", "obw", "full-detected", "full-bandwidth"]), + default="nbw", + help="Frequency bounding method", +) +@click.option("--nfft", type=int, default=None, help="FFT size for frequency calculation") +@click.option("--obw-power", type=float, default=0.99, help="Power percentage for OBW/NBW (0.98-0.9999)") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def energy( + input, + label, + threshold, + segments, + window_size, + min_distance, + freq_method, + nfft, + obw_power, + annotation_type, + output, + overwrite, + quiet, +): + """Auto-detect signals using energy-based method. + + Detects bursts based on energy above noise floor. Best for bursty signals + and intermittent transmissions. + + \b + Frequency Bounding Methods: + nbw - Nominal bandwidth (default, best for real signals) + obw - Occupied bandwidth (more conservative, includes sidelobes) + full-detected - Lowest to highest spectral component + full-bandwidth - Entire Nyquist span + + \b + Examples: + ria annotate energy capture.sigmf-data --label burst + ria annotate energy signal.npy --threshold 1.5 --min-distance 10000 + ria annotate energy signal.sigmf-data --freq-method obw + ria annotate energy signal.sigmf-data --freq-method full-detected + + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting signals using energy-based method...") + click.echo(" Time detection:") + click.echo(f" Segments: {segments}") + click.echo(f" Threshold: {threshold}x noise floor") + click.echo(f" Window size: {window_size} samples") + click.echo(f" Min distance: {min_distance} samples") + click.echo(f" Frequency bounds: {freq_method}") + + try: + initial_count = len(recording.annotations) + recording = detect_signals_energy( + recording, + k=segments, + threshold_factor=threshold, + window_size=window_size, + min_distance=min_distance, + label=label, + annotation_type=annotation_type, + freq_method=freq_method, + nfft=nfft, + obw_power=obw_power, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Energy detection failed: {e}") + + +# ============================================================================ +# CUSUM detection subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--label", type=str, default="segment", help="Annotation label") +@click.option("--min-duration", type=float, default=5.0, help="Min duration in ms (prevents over-segmentation)") +@click.option("--window-size", type=int, default=1, help="Smoothing window size") +@click.option("--tolerance", type=int, default=-1, help="Sample tolerance for merging") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def cusum(input, label, min_duration, window_size, tolerance, annotation_type, output, overwrite, quiet): + """Auto-detect segments using CUSUM method. + + Detects signal state changes (on/off, amplitude transitions). Best for + segmenting continuous signals. + + IMPORTANT: Always specify --min-duration to prevent excessive segmentation. + + \b + Examples: + ria annotate cusum signal.sigmf-data --min-duration 5.0 + ria annotate cusum data.npy --min-duration 10.0 --label state + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting segments using CUSUM...") + click.echo(f" Min duration: {min_duration} ms") + if window_size != 1: + click.echo(f" Window size: {window_size} samples") + + try: + initial_count = len(recording.annotations) + recording = annotate_with_cusum( + recording, + label=label, + window_size=window_size, + min_duration=min_duration, + tolerance=tolerance, + annotation_type=annotation_type, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"CUSUM detection failed: {e}") + + +# ============================================================================ +# Threshold detection subcommand +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--threshold", type=float, required=True, help="Threshold (0.0-1.0, fraction of max magnitude)") +@click.option("--label", type=str, default=None, help="Annotation label") +@click.option("--window-size", type=int, default=None, help="Smoothing window size in samples (default: 1ms at recording sample rate)") +@click.option( + "--type", + "annotation_type", + type=click.Choice(["standalone", "parallel", "intersection"]), + default="standalone", + help="Annotation type", +) +@click.option("--channel", type=int, default=0, help="Channel index to annotate (default: 0)") +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +def threshold(input, threshold, label, window_size, annotation_type, channel, output, overwrite, quiet): + """Auto-detect signals using threshold method. + + Detects samples above a percentage of maximum magnitude. Best for simple + power-based detection. + + \b + Examples: + ria annotate threshold signal.sigmf-data --threshold 0.7 --label wifi + ria annotate threshold data.npy --threshold 0.5 --window-size 2048 + """ + if not (0.0 <= threshold <= 1.0): + raise click.ClickException(f"--threshold must be between 0.0 and 1.0, got {threshold}") + + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + if not quiet: + click.echo("\nDetecting signals using threshold qualifier...") + click.echo(f" Threshold: {threshold * 100:.1f}% of max magnitude") + click.echo(f" Window size: {'auto (1ms)' if window_size is None else f'{window_size} samples'}") + click.echo(f" Channel: {channel}") + + try: + initial_count = len(recording.annotations) + recording = threshold_qualifier( + recording, + threshold=threshold, + window_size=window_size, + label=label, + annotation_type=annotation_type, + channel=channel, + ) + added = len(recording.annotations) - initial_count + + if not quiet: + click.echo(f" ✓ Added {added} annotation(s)") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Threshold detection failed: {e}") + + +# ============================================================================ +# Separate subcommand (Phase 2: Parallel signal separation) +# ============================================================================ + + +@annotate.command() +@click.argument("input", type=click.Path(exists=True)) +@click.option("--indices", type=str, help="Comma-separated annotation indices to split (default: all)") +@click.option("--nfft", type=int, default=65536, help="FFT size for spectral analysis") +@click.option("--noise-threshold-db", type=float, help="Noise floor threshold in dB (auto-estimated if not specified)") +@click.option("--min-component-bw", type=float, default=50e3, help="Min component bandwidth in Hz") +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") +@click.option("--quiet", is_flag=True, help="Quiet mode") +@click.option("--verbose", is_flag=True, help="Verbose output (show detected components)") +def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output, overwrite, quiet, verbose): + """ + Auto-detect parallel frequency-offset signals and split into sub-bands. + + Provides methods to detect and separate overlapping frequency-domain signals + that occupy the same time window but different frequency bands. + + Detects multiple frequency components within single annotations and splits + them into separate annotations. Uses spectral peak detection with dual + bandwidth estimation. + + \b + Key Features: + - Spectral peak detection for frequency components + - Auto noise floor estimation (or user-specified) + - Dual bandwidth estimation: -3dB primary, cumulative power fallback + - Handles narrowband and wide signals (OFDM) + + \b + Examples: + ria annotate separate capture.sigmf-data + ria annotate separate signal.npy --indices 0,1,2 + ria annotate separate data.sigmf-data --noise-threshold-db -70 + ria annotate separate signal.npy --min-component-bw 100000 + + """ + try: + recording = load_recording(input) + if not quiet: + click.echo(f"Loaded: {input}") + except Exception as e: + raise click.ClickException(f"Failed to load recording: {e}") + + # Parse indices if specified + indices_list = get_indices_list(indices=indices, recording=recording) + + if len(recording.annotations) == 0: + if not quiet: + click.echo("No annotations to split") + return + + if not quiet: + click.echo("\nSplitting annotations by frequency components...") + click.echo(f" Input annotations: {len(recording.annotations)}") + if indices_list: + click.echo(f" Splitting indices: {indices_list}") + click.echo(f" FFT size: {nfft}") + if noise_threshold_db is not None: + click.echo(f" Noise threshold: {noise_threshold_db} dB") + else: + click.echo(" Noise threshold: auto-estimated") + click.echo(f" Min component BW: {format_frequency(min_component_bw)}") + + try: + initial_count = len(recording.annotations) + + recording = split_recording_annotations( + recording, + indices=indices_list, + nfft=nfft, + noise_threshold_db=noise_threshold_db, + min_component_bw=min_component_bw, + ) + + final_count = len(recording.annotations) + added = final_count - initial_count + + if not quiet: + click.echo(f" ✓ Output annotations: {final_count} ({'+' if added >= 0 else ''}{added} change)") + if verbose and added > 0: + click.echo("\n Details:") + for i in range(initial_count, final_count): + ann = recording.annotations[i] + freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}" + click.echo( + f" [{i}] samples {format_sample_count(ann.sample_start)}-" + f"{format_sample_count(ann.sample_start + ann.sample_count)}: {freq_range}" + ) + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") + except Exception as e: + raise click.ClickException(f"Spectral separation failed: {e}") diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py index 60ddba9..e942386 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py @@ -3,6 +3,7 @@ This module contains all the CLI bindings for the ria package. """ +from .annotate import annotate from .capture import capture from .combine import combine from .convert import convert diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py index f2e14ba..1026b4a 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py @@ -232,8 +232,8 @@ def generate(): \b Examples: - utils synth chirp -b 1e6 -p 0.01 -s 10e6 -o chirp_basic.sigmf - utils synth fsk -M 2 -r 100e3 -s 2e6 -o fsk2_basic.sigmf + ria synth chirp -b 1e6 -p 0.01 -s 10e6 -o chirp_basic.sigmf + ria synth fsk -M 2 -r 100e3 -s 2e6 -o fsk2_basic.sigmf """ pass diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py index 94524f2..5d6e724 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py @@ -264,13 +264,13 @@ def transform(): Examples:\n \b # List available augmentations - utils transform augment --list + ria transform augment --list \b # Apply channel swap - utils transform augment channel_swap input.npy + ria transform augment channel_swap input.npy \b # Apply AWGN impairment - utils transform impair awgn input.npy --snr-db 15 + ria transform impair awgn input.npy --snr-db 15 """ pass diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py index 8e0b51f..9fecec3 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/view.py @@ -7,7 +7,7 @@ from typing import Optional import click from ria_toolkit_oss.io.recording import from_npy, load_recording -from ria_toolkit_oss.view.view_signal import view_channels, view_sig +from ria_toolkit_oss.view.view_signal import view_annotations, view_channels, view_sig from ria_toolkit_oss.view.view_signal_simple import view_simple_sig from .common import echo_progress, echo_verbose, load_yaml_config @@ -35,6 +35,7 @@ VISUALIZATION_TYPES = { ], }, "channels": {"function": view_channels, "description": "Multi-channel IQ and spectrogram view", "options": []}, + "annotations": {"function": view_annotations, "description": "Annotated spectrogram view", "options": ["channel", "dark"]}, } diff --git a/tests/ria_toolkit_oss_cli/README.md b/tests/ria_toolkit_oss_cli/README.md index 1c4cc8e..3e78415 100644 --- a/tests/ria_toolkit_oss_cli/README.md +++ b/tests/ria_toolkit_oss_cli/README.md @@ -1,6 +1,6 @@ # CLI Tests -Comprehensive test suite for the utils CLI commands. +Comprehensive test suite for the ria CLI commands. ## Test Structure diff --git a/tests/ria_toolkit_oss_cli/__init__.py b/tests/ria_toolkit_oss_cli/__init__.py index 77c8a64..26d94ee 100644 --- a/tests/ria_toolkit_oss_cli/__init__.py +++ b/tests/ria_toolkit_oss_cli/__init__.py @@ -1 +1 @@ -"""Tests for utils CLI commands.""" +"""Tests for ria CLI commands."""