Source code for intan.io._npz_utils

import os
import glob
from typing import Union, Sequence, List, Dict, Any, Tuple, Optional
import numpy as np
from tqdm import tqdm


def _is_empty_channel_names(val) -> bool:
    """True if channel_names is missing/empty/None or a 1-D array of length 0."""
    if val is None:
        return True
    if isinstance(val, (list, tuple)):
        return len(val) == 0
    if isinstance(val, np.ndarray):
        return val.size == 0
    # strings or other scalars are not valid channel name containers
    return False


[docs] def list_npz_files(path_or_glob: Union[str, Sequence[str]], recursive: bool = False) -> List[str]: """Expand dirs/globs/files into a sorted, de-duplicated list of .npz paths.""" def _expand_one(p: str) -> List[str]: p = os.path.expanduser(os.path.expandvars(p)) if os.path.isdir(p): pattern = "**/*.npz" if recursive else "*.npz" return sorted(glob.glob(os.path.join(p, pattern), recursive=recursive)) if any(ch in p for ch in "*?[]"): # allow ** when recursive=True return sorted(glob.glob(p, recursive=recursive)) if os.path.isfile(p) and p.lower().endswith(".npz"): return [p] return [] if isinstance(path_or_glob, (list, tuple)): out = [] for item in path_or_glob: out.extend(_expand_one(str(item))) # de-dup while preserving order seen, uniq = set(), [] for p in out: if p not in seen: uniq.append(p); seen.add(p) return uniq return _expand_one(str(path_or_glob))
[docs] def find_npz_by_label(npz_dir: str, label: str, recursive: bool = True) -> list[str]: """Return NPZ paths whose filename contains `label` (case-insensitive).""" label_lc = label.lower().strip() data = [p for p in list_npz_files(npz_dir, recursive=recursive) if label_lc in os.path.basename(p).lower()] return data
[docs] def load_npz_record(path: str) -> tuple[np.ndarray, str]: """Load one NPZ file -> (emg (C,N), label:str). Raises if keys missing.""" with np.load(path, allow_pickle=True) as d: if "emg" not in d or "label" not in d: raise KeyError(f"{os.path.basename(path)} missing 'emg' or 'label' keys.") emg = d["emg"] label = d["label"].item() # np.object_ -> str return emg, str(label)
[docs] def save_as_npz(result: dict, file_path: str = None, use_compressed_format=True, verbose: bool = False) -> None: """ Save the rhd data as a .npz file. uses the compressed format by default Args: result (dict): Dictionary containing the Open Ephys session data. Must contain keys: 'amplifier_data', 't_amplifier', 'sample_rate', 'recording_name'. file_path (str, optional): Path to save the .npz file. If None, uses the recording name. Returns: None """ if not isinstance(result, dict): raise ValueError("Input must be dict data from RHD file.") if use_compressed_format: save_as_npz_compressed(result, file_path=file_path, optimize_dtypes=False, show_progress=True, verbose=verbose) return required_keys = ['amplifier_data', 't_amplifier'] if not all(key in result for key in required_keys): raise KeyError(f"Missing one of the required keys: {required_keys}") if file_path is None: if 'recording_name' not in result: raise KeyError("file_path is None and result has no 'recording_name'.") # Save to local directory using file name file_path = result['recording_name'] + '.npz' elif not file_path.endswith('.npz'): file_path += '.npz' # Ensure the directory exists directory = os.path.dirname(file_path) if directory and not os.path.exists(directory): os.makedirs(directory) # Save the data to a .npz file print(f" Saving data to {file_path}...") np.savez(file_path, **{key: result[key] for key in result.keys()}) print(f"Data saved to {file_path}")
[docs] def save_as_npz_compressed(result: dict, file_path: str = None, optimize_dtypes: bool = True, show_progress: bool = False, verbose: bool = True) -> Dict[str, Any]: """ Save the rhd data as a compressed .npz file (70-85% smaller than standard NPZ). This function creates significantly smaller files than save_as_npz() with no loss of data quality. Recommended for all use cases. Args: result (dict): Dictionary containing the Open Ephys session data. Must contain keys: 'amplifier_data', 't_amplifier', 'sample_rate', 'recording_name'. file_path (str, optional): Path to save the .npz file. If None, uses the recording name. optimize_dtypes (bool, default=True): Optimize data types to reduce size: - Keep int16 as int16 (ADC data) - Convert float64 to float32 where appropriate show_progress (bool, default=False): Show progress bar during save (requires tqdm). verbose (bool, default=True): Print save status messages. Returns: dict: Statistics about the saved file including: - file_path: Path to saved file - original_size_mb: Size before optimization - file_size_mb: Final compressed file size - compression_ratio: How much smaller (e.g., 4.0 = 4x smaller) - space_saved_percent: Percentage reduction Examples: save_as_npz_compressed(result) # With custom path and progress bar stats = save_as_npz_compressed(result, "data.npz", show_progress=True) print(f"Saved {stats['space_saved_percent']:.1f}% space!") """ # Validation if not isinstance(result, dict): raise ValueError("Input must be dict data from RHD file.") required_keys = ['amplifier_data', 't_amplifier'] if not all(key in result for key in required_keys): raise KeyError(f"Missing one of the required keys: {required_keys}") # Determine file path if file_path is None: if 'recording_name' not in result: raise KeyError("file_path is None and result has no 'recording_name'.") file_path = result['recording_name'] + '.npz' elif not file_path.endswith('.npz'): file_path += '.npz' # Ensure directory exists directory = os.path.dirname(file_path) if directory and not os.path.exists(directory): os.makedirs(directory) if show_progress and verbose: print("Warning: tqdm not installed. Install with: pip install tqdm") show_progress = False # Prepare data save_dict = {} original_size = 0 optimized_size = 0 if verbose: print(f"Saving compressed data to {file_path}...") # Process data with optional optimization items = list(result.items()) iterator = tqdm(items, desc="Processing data", disable=not show_progress) if show_progress else items for key, value in iterator: if show_progress: iterator.set_description(f"Processing: {key[:25]}") if isinstance(value, np.ndarray): original_size += value.nbytes if optimize_dtypes: # Smart dtype optimization if value.dtype == np.float64: if 't_' in key.lower() or 'time' in key.lower(): # Time vectors: float32 has plenty of precision value = value.astype(np.float32) elif 'amplifier' in key.lower() or 'emg' in key.lower() or 'aux' in key.lower(): # ADC data: check if it's int16 range stored as float if np.all(np.abs(value) < 32767): value = value.astype(np.int16) else: value = value.astype(np.float32) else: # Other float data: use float32 value = value.astype(np.float32) elif value.dtype == np.int64: # Downcast large integers where safe max_val = np.max(np.abs(value)) if max_val < 32767: value = value.astype(np.int16) elif max_val < 2147483647: value = value.astype(np.int32) optimized_size += value.nbytes save_dict[key] = value if show_progress: iterator.close() # Save with gzip compression np.savez_compressed(file_path, **save_dict) # Get statistics file_size = os.path.getsize(file_path) compression_ratio = original_size / file_size if file_size > 0 else 1.0 if verbose: print(f"Data saved to {file_path}") print(f" Original size: {original_size / 1024 ** 2:.2f} MB") if optimize_dtypes: print(f" After dtype optimization: {optimized_size / 1024 ** 2:.2f} MB") print(f" Compressed file size: {file_size / 1024 ** 2:.2f} MB") print(f" Compression ratio: {compression_ratio:.2f}x") print(f" Space saved: {(1 - file_size / original_size) * 100:.1f}%") return { 'file_path': file_path, 'original_size_mb': original_size / 1024 ** 2, 'optimized_size_mb': optimized_size / 1024 ** 2, 'file_size_mb': file_size / 1024 ** 2, 'compression_ratio': compression_ratio, 'space_saved_percent': (1 - file_size / original_size) * 100 if original_size > 0 else 0 }
[docs] def load_npz_file(file_path: Union[str, Sequence[str]], verbose: bool = False, allow_many: bool = False) -> Union[Dict, List[Dict]]: """ Load an NPZ and reconstruct a convenient dict, normalizing common field aliases. Supported aliases (all preserved; normalized fields always populated when available): - EMG matrix: 'emg', 'emg_data', 'amplifier_data' -> normalized to result['amplifier_data'] - Time vector: 't', 'time_vector', 't_amplifier' -> normalized to result['t_amplifier'] - Sampling rate (Hz): 'fs', 'sampling_rate', 'sample_rate', result['frequency_parameters']['amplifier_sample_rate'], OR inferred from time vector -> normalized to result['_fs_Hz'] & result['sample_rate'] - Channel names: 'ch_names', 'channel_names', 'amplifier_channels[*].native_channel_name' -> normalized to result['channel_names'] Also passes through any extra keys (e.g., provenance: 'source_file', 'source_gesture', 'source_local_index', 'source_name', 'gestures_combined', 'fs_reported', 'fs_effective', 'align_mode', 'dt_target', 't_overlap', 'reorder_info', etc.). If `file_path` resolves to multiple files and `allow_many=True`, returns a list of dicts. Otherwise (legacy behavior) the first match is loaded. """ # If user passed a sequence, expand it if isinstance(file_path, (list, tuple)): files = list_npz_files(file_path) if len(files) == 0: raise FileNotFoundError(f"No NPZ files found for: {file_path}") if allow_many: return [load_npz_file(fp, verbose=verbose, allow_many=False) for fp in files] file_path = files[0] # If user passed a single string, it might still be a dir or glob if isinstance(file_path, str): p = os.path.expanduser(os.path.expandvars(file_path)) if os.path.isdir(p) or any(ch in p for ch in "*?[]"): files = list_npz_files(p) if len(files) == 0: raise FileNotFoundError(f"No NPZ files found in: {file_path}") if allow_many: return [load_npz_file(fp, verbose=verbose, allow_many=False) for fp in files] file_path = files[0] # --- guaranteed single file path --- z = np.load(file_path, allow_pickle=True) def _to_native(x): # Convert numpy object arrays back to python containers when appropriate if isinstance(x, np.ndarray): # 0-D object -> Python object if possible if x.dtype == object and x.ndim == 0: try: return x.item() except Exception: return x # object arrays -> list if x.dtype == object: try: return x.tolist() except Exception: return x # 1D unicode/bytes -> list[str] if x.ndim == 1 and x.dtype.kind in ("U", "S"): return x.tolist() return x # Raw dict (preserve originals) result: Dict = {k: _to_native(z[k]) for k in z.files} # Basic file metadata result['export_basename'] = os.path.splitext(os.path.basename(file_path))[0] result['export_basepath'] = os.path.dirname(os.path.abspath(file_path)) result.setdefault('file_name', os.path.basename(file_path)) result.setdefault('file_path', os.path.abspath(file_path)) # -------- normalize EMG matrix -------- emg = None for key in ('amplifier_data', 'emg', 'emg_data'): if key in result: emg = result[key] break # ensure ndarray (channels x samples) if isinstance(emg, list): emg = np.asarray(emg) if emg is not None and not isinstance(emg, np.ndarray): emg = np.asarray(emg) if emg is not None: if emg.ndim != 2: raise ValueError(f"{file_path}: EMG must be 2D (channels x samples), got shape {emg.shape}") result['amplifier_data'] = emg # normalized # -------- normalize time vector -------- t = None for key in ('t_amplifier', 't', 'time_vector'): if key in result: t = result[key] break if isinstance(t, list): t = np.asarray(t) if t is not None and not isinstance(t, np.ndarray): t = np.asarray(t) if t is not None: if t.ndim != 1: raise ValueError(f"{file_path}: time vector must be 1D, got shape {t.shape}") result['t_amplifier'] = t # normalized # -------- normalize sampling rate (Hz) -------- fs = None # Prefer explicit scalar fields for key in ('_fs_Hz', 'fs', 'emg_fs', 'sampling_rate', 'sample_rate', 'amplifier_sample_rate'): if key in result: try: fs = float(result[key]) break except Exception: pass # Try nested frequency_parameters if fs is None and isinstance(result.get('frequency_parameters'), dict): try: fs = float(result['frequency_parameters'].get('amplifier_sample_rate')) except Exception: pass # Try effective fs from alignment scripts if fs is None and 'fs_effective' in result: try: # could be scalar or 1D array; take first if array fse = result['fs_effective'] if isinstance(fse, (list, tuple, np.ndarray)): fse = float(np.asarray(fse).ravel()[0]) fs = float(fse) except Exception: pass # Derive from time vector if still missing if fs is None and isinstance(result.get('t_amplifier'), np.ndarray) and result['t_amplifier'].size > 1: dt = float(np.median(np.diff(result['t_amplifier']))) if dt > 0: fs = 1.0 / dt if fs is not None: result['_fs_Hz'] = float(fs) result['sample_rate'] = float(fs) # -------- n_samples & n_channels -------- n_samp = None n_chan = None if isinstance(result.get('t_amplifier'), np.ndarray): n_samp = int(result['t_amplifier'].size) if isinstance(result.get('amplifier_data'), np.ndarray): n_chan = int(result['amplifier_data'].shape[0]) n_samp = int(result['amplifier_data'].shape[1]) if n_samp is None else n_samp # fallbacks from common keys (if present) if n_samp is None and 'n_samples' in result: try: n_samp = int(result['n_samples']) except Exception: pass if n_chan is None and 'n_channels' in result: try: n_chan = int(result['n_channels']) except Exception: pass result['n_samples'] = n_samp result['n_channels'] = n_chan # -------- channel names normalization -------- chn = result.get('channel_names') if _is_empty_channel_names(chn): # try 'ch_names' chn_alt = result.get('ch_names') if not _is_empty_channel_names(chn_alt): # ensure list[str] result['channel_names'] = [str(x) for x in chn_alt] else: # provenance-sourced names src_names = result.get('source_name') src_gest = result.get('source_gesture') if (isinstance(src_names, (list, tuple)) or isinstance(src_names, np.ndarray)): try: # if gesture available, keep "GESTURE:NAME" else just NAME if isinstance(src_gest, (list, tuple, np.ndarray)) and len(src_gest) == len(src_names): result['channel_names'] = [f"{str(g)}:{str(n)}" for g, n in zip(src_gest, src_names)] else: result['channel_names'] = [str(n) for n in src_names] except Exception: pass # If still missing, synthesize CHi if _is_empty_channel_names(result.get('channel_names')) and isinstance(result.get('amplifier_data'), np.ndarray): C = result['amplifier_data'].shape[0] result['channel_names'] = [f"CH{i}" for i in range(C)] # -------- sanity: fs vs time-derived ---------- # (non-fatal: warn in verbose mode if big mismatch) if verbose and (result.get('_fs_Hz') is not None) and isinstance(result.get('t_amplifier'), np.ndarray): tvec = result['t_amplifier'] if tvec.size > 1: dt_eff = float(np.median(np.diff(tvec))) if dt_eff > 0: fs_eff = 1.0 / dt_eff delta = abs(fs_eff - float(result['_fs_Hz'])) if delta > 1e-3 * max(1.0, float(result['_fs_Hz'])): print(f"[load_npz_file] Warning: reported fs={result['_fs_Hz']:.6g} " f"differs from effective {fs_eff:.6g} (Δ={delta:.6g}) for {file_path}") if verbose: ad = result.get('amplifier_data') print(f"[load_npz_file] Loaded: {file_path}") print(f" fs: {result.get('_fs_Hz')}, n_samples: {result.get('n_samples')}, " f"channels: {ad.shape[0] if isinstance(ad, np.ndarray) else 'NA'}") return result
[docs] def load_npz_files(paths: Union[str, Sequence[str]], verbose: bool = False) -> List[Dict]: """ Load MANY NPZ files, returning a list of dicts (one per NPZ). ``paths`` may be a directory (loads all ``*.npz`` files), a file path, a glob pattern, or a list/tuple containing any combination of those. """ files = list_npz_files(paths) if len(files) == 0: raise FileNotFoundError(f"No NPZ files found for: {paths}") return [load_npz_file(fp, verbose=verbose) for fp in files]
# ============================================================================= # Training Dataset Utilities # =============================================================================
[docs] def load_training_dataset( npz_path: str, verbose: bool = False, ) -> Dict[str, Any]: """ Load a training dataset NPZ with standardized field extraction. Handles various field naming conventions and returns a normalized dict. Args: npz_path: Path to training dataset NPZ file verbose: Print loading info Returns: Dict with standardized keys: - X: Feature matrix (n_windows, n_features) - y: Labels as strings (n_windows,) - y_id: Labels as integers (n_windows,) if available - class_names: Sorted unique class names - label_to_id: Dict mapping class name → integer ID - emg_fs: Sampling frequency (Hz) - window_ms: Feature window size (ms) - step_ms: Window step size (ms) - channel_names: List of channel names - selected_channels: List of channel indices used - feature_spec: Feature specification dict - metadata: Any additional metadata Example: >>> data = load_training_dataset("training_dataset.npz") >>> X, y = data["X"], data["y"] >>> print(f"Loaded {X.shape[0]} samples, {len(data['class_names'])} classes") """ import json with np.load(npz_path, allow_pickle=True) as d: result = {} # Core data result["X"] = d["X"] # Labels: prefer y_id if available, else y if "y_id" in d.files: result["y_id"] = d["y_id"] result["y"] = d["y"] if "y" in d.files else None else: result["y"] = d["y"] result["y_id"] = None # Class information if "class_names" in d.files: cn = d["class_names"] result["class_names"] = cn.tolist() if hasattr(cn, "tolist") else list(cn) else: # Derive from y y_vals = result["y"] if result["y"] is not None else result["y_id"] result["class_names"] = sorted(set(y_vals)) if y_vals is not None else [] # Label mapping if "label_to_id_json" in d.files: raw = d["label_to_id_json"] try: raw = raw.item() if getattr(raw, "shape", ()) == () else str(raw) result["label_to_id"] = json.loads(str(raw)) except Exception: result["label_to_id"] = {c: i for i, c in enumerate(result["class_names"])} else: result["label_to_id"] = {c: i for i, c in enumerate(result["class_names"])} # Preprocessing parameters result["emg_fs"] = float(d["emg_fs"]) if "emg_fs" in d.files else None result["window_ms"] = int(d["window_ms"]) if "window_ms" in d.files else None result["step_ms"] = int(d["step_ms"]) if "step_ms" in d.files else None # Channel information if "channel_names" in d.files: cn = d["channel_names"] result["channel_names"] = cn.tolist() if hasattr(cn, "tolist") else list(cn) else: result["channel_names"] = [] if "selected_channels" in d.files: sc = d["selected_channels"] result["selected_channels"] = sc.tolist() if hasattr(sc, "tolist") else list(sc) else: result["selected_channels"] = [] # Feature specification if "feature_spec" in d.files: raw = d["feature_spec"] try: raw = raw.item() if getattr(raw, "shape", ()) == () else raw result["feature_spec"] = json.loads(str(raw)) except Exception: result["feature_spec"] = None else: result["feature_spec"] = None # Additional metadata result["metadata"] = { "file_path": os.path.abspath(npz_path), "modality": str(d["modality"].item()) if "modality" in d.files else "emg", } if verbose: print(f"[load_training_dataset] Loaded: {npz_path}") print(f" X shape: {result['X'].shape}") print(f" Classes: {result['class_names']}") print(f" Window: {result['window_ms']}ms, Step: {result['step_ms']}ms") return result
[docs] def load_and_merge_datasets( npz_paths: Sequence[str], verbose: bool = False, ) -> Tuple[np.ndarray, np.ndarray, Dict[str, Any]]: """ Load and concatenate multiple training datasets. Validates that all datasets have compatible feature dimensions and merges class labels appropriately. Args: npz_paths: List of paths to training dataset NPZ files verbose: Print loading info Returns: Tuple of (X, y, metadata) where: - X: Concatenated feature matrix (total_windows, n_features) - y: Concatenated string labels (total_windows,) - metadata: Merged metadata dict Raises: ValueError: If feature dimensions don't match across datasets Example: >>> X, y, meta = load_and_merge_datasets([ ... "session1_dataset.npz", ... "session2_dataset.npz", ... ]) """ if not npz_paths: raise ValueError("No dataset paths provided") all_X = [] all_y = [] all_classes = set() metadata_list = [] feature_dim = None for i, path in enumerate(npz_paths): data = load_training_dataset(path, verbose=verbose) # Validate feature dimension consistency if feature_dim is None: feature_dim = data["X"].shape[1] elif data["X"].shape[1] != feature_dim: raise ValueError( f"Feature dimension mismatch: {path} has {data['X'].shape[1]} features, " f"expected {feature_dim}" ) all_X.append(data["X"]) # Use string labels y = data["y"] if data["y"] is not None else data["y_id"] all_y.append(np.asarray(y, dtype=object)) all_classes.update(data["class_names"]) metadata_list.append(data) # Concatenate X = np.concatenate(all_X, axis=0) y = np.concatenate(all_y, axis=0) # Build unified class mapping class_names = sorted(all_classes) label_to_id = {c: i for i, c in enumerate(class_names)} # Merge metadata (use first file's params, note all sources) first = metadata_list[0] merged_metadata = { "class_names": class_names, "label_to_id": label_to_id, "emg_fs": first["emg_fs"], "window_ms": first["window_ms"], "step_ms": first["step_ms"], "channel_names": first["channel_names"], "selected_channels": first["selected_channels"], "feature_spec": first["feature_spec"], "source_files": list(npz_paths), "n_sources": len(npz_paths), } if verbose: print(f"[load_and_merge_datasets] Merged {len(npz_paths)} datasets") print(f" Total samples: {X.shape[0]}") print(f" Unified classes: {class_names}") return X, y, merged_metadata
[docs] def save_training_dataset( save_path: str, X: np.ndarray, y: np.ndarray, emg_fs: float, window_ms: int, step_ms: int, channel_names: List[str], selected_channels: List[int] = None, feature_spec: Dict = None, channel_map: str = None, channel_map_file: str = None, modality: str = "emg", ) -> None: """ Save a training dataset to NPZ with standardized format. Args: save_path: Output file path X: Feature matrix (n_windows, n_features) y: String labels (n_windows,) emg_fs: Sampling frequency (Hz) window_ms: Feature window size (ms) step_ms: Window step size (ms) channel_names: List of channel names in order used selected_channels: Original channel indices (if subset) feature_spec: Feature specification dict channel_map: Name of channel mapping used (for reproducibility) channel_map_file: Path to channel mapping file modality: Data modality (default: "emg") Example: >>> save_training_dataset( ... "training_dataset.npz", ... X=features, y=labels, ... emg_fs=2000, window_ms=200, step_ms=50, ... channel_names=["A-001", "A-002", ...], ... ) """ import json # Ensure output directory exists out_dir = os.path.dirname(save_path) if out_dir and not os.path.exists(out_dir): os.makedirs(out_dir) # Build label mappings class_names = sorted(set(y)) label_to_id = {c: i for i, c in enumerate(class_names)} y_id = np.array([label_to_id[lab] for lab in y], dtype=np.int32) np.savez( save_path, # Core data X=X, y=y, y_id=y_id, # Label info class_names=np.array(class_names, dtype=object), label_to_id_json=np.array(json.dumps(label_to_id), dtype=object), # Preprocessing params emg_fs=emg_fs, window_ms=window_ms, step_ms=step_ms, # Channel info channel_names=np.array(channel_names, dtype=object), selected_channels=np.array(selected_channels if selected_channels else [], dtype=int), # Feature info feature_spec=json.dumps(feature_spec) if feature_spec else "", # Metadata modality=np.array(modality, dtype=object), channel_mapping_name=np.array(channel_map or "", dtype=object), channel_mapping_file=np.array(channel_map_file or "", dtype=object), )