target_min: Minimum output value (inclusive). target_max: Maximum output value (inclusive). target_type: NumPy dtype for output array (default: np.uint8). Returns: Normalized NumPy array of dtype `target_type`, or None on error. """ """Image processing helpers copied into VideoReceiverSFP for standalone operation. Implements the same behavior used by ControlPanel so output images are visually identical. Provides: - normalize_image: uses OpenCV `cv2.normalize` with dtype handling - create_brightness_contrast_lut - apply_brightness_contrast - apply_color_palette: wraps `cv2.applyColorMap` - resize_image: thin wrapper over `cv2.resize` """ from typing import Optional import logging import numpy as np try: import cv2 except Exception: cv2 = None # type: ignore def create_brightness_contrast_lut(brightness: int = 0, contrast: float = 1.0) -> np.ndarray: """Create a 256-element uint8 LUT equivalent to ControlPanel's. Args: brightness: int (-255..255) contrast: float (>0) Returns: numpy.ndarray shape (256,) dtype uint8 """ log_prefix = "[VRSFP B/C LUT]" logging.getLogger().debug(f"{log_prefix} Creating B/C LUT B={brightness} C={contrast}") contrast = max(0.01, float(contrast)) lut_values = np.arange(256, dtype=np.float32) adjusted = (lut_values * contrast) + float(brightness) lut = np.clip(np.round(adjusted), 0, 255).astype(np.uint8) return lut def apply_brightness_contrast(image: np.ndarray, brightness: int = 0, contrast: float = 1.0) -> np.ndarray: """Apply B/C using a LUT (expects uint8 input). Returns image or original on error.""" log_prefix = "[VRSFP B/C Apply]" if image is None or not hasattr(image, 'dtype'): logging.getLogger().warning(f"{log_prefix} invalid image") return image if image.dtype != np.uint8: logging.getLogger().warning(f"{log_prefix} expected uint8, got {image.dtype}") return image try: lut = create_brightness_contrast_lut(brightness, contrast) if cv2 is not None: return cv2.LUT(image, lut) else: return image except Exception: logging.getLogger().exception(f"{log_prefix} failed to apply B/C") return image def apply_color_palette(image: np.ndarray, palette: str) -> np.ndarray: """Apply an OpenCV colormap by name (returns BGR image). palette should be names like 'JET', 'HOT', etc. Case-insensitive. """ log_prefix = "[VRSFP Palette]" if image is None or cv2 is None: logging.getLogger().warning(f"{log_prefix} cv2 not available or image is None") return image if image.dtype != np.uint8: logging.getLogger().warning(f"{log_prefix} expected uint8 image for palette application") try: img_gray = image.astype(np.uint8) except Exception: return image else: img_gray = image try: colormap_name = f"COLORMAP_{palette.upper()}" colormap = getattr(cv2, colormap_name) colorized = cv2.applyColorMap(img_gray, colormap) return colorized except AttributeError: logging.getLogger().error(f"{log_prefix} palette {palette} not found; returning BGR grayscale") return cv2.cvtColor(img_gray, cv2.COLOR_GRAY2BGR) except Exception: logging.getLogger().exception(f"{log_prefix} error applying palette") return cv2.cvtColor(img_gray, cv2.COLOR_GRAY2BGR) def normalize_image(image: Optional[np.ndarray], target_min: int = 0, target_max: int = 255, target_type=np.uint8) -> Optional[np.ndarray]: """Normalize image using OpenCV to match ControlPanel behavior. Uses cv2.normalize with dtype mapping and final cast to `target_type`. """ log_prefix = "[VRSFP Normalize]" logging.getLogger().debug(f"{log_prefix} Normalizing image (dtype={getattr(image,'dtype',None)}) to {target_type}") if image is None or getattr(image, 'size', 0) == 0: logging.getLogger().warning(f"{log_prefix} called with None or empty image") return None try: # decide cv dtype if target_type == np.uint8: cv_dtype = cv2.CV_8U if cv2 is not None else -1 elif target_type == np.uint16: cv_dtype = cv2.CV_16U elif target_type == np.int16: cv_dtype = cv2.CV_16S elif target_type == np.int32: cv_dtype = cv2.CV_32S elif target_type == np.float32: cv_dtype = cv2.CV_32F elif target_type == np.float64: cv_dtype = cv2.CV_64F else: cv_dtype = -1 if cv2 is not None: normalized = cv2.normalize(src=image, dst=None, alpha=target_min, beta=target_max, norm_type=cv2.NORM_MINMAX, dtype=cv_dtype) else: # fallback to simple numpy minmax arr = np.asarray(image) vmin = float(np.min(arr)) vmax = float(np.max(arr)) if vmin == vmax: return np.full(arr.shape, target_min, dtype=target_type) scale = (target_max - target_min) / (vmax - vmin) out = (arr.astype(np.float64) - vmin) * scale + target_min normalized = np.clip(out, target_min, target_max) if normalized.dtype != target_type: try: normalized = normalized.astype(target_type) except Exception: logging.getLogger().exception(f"{log_prefix} failed casting to target_type") return image return normalized except Exception: logging.getLogger().exception(f"{log_prefix} unexpected error") return image def resize_image(image: np.ndarray, width: int, height: int, interpolation=None) -> Optional[np.ndarray]: if interpolation is None: interpolation = cv2.INTER_LINEAR if cv2 is not None else None if image is None or width <= 0 or height <= 0: return image try: if cv2 is not None: return cv2.resize(image, (width, height), interpolation=interpolation) else: return image except Exception: logging.getLogger().exception('[VRSFP Resize] failed') return None