# --- START OF FILE image_recorder.py --- # image_recorder.py """ Handles saving SAR images as georeferenced TIFF files (GeoTIFF). Includes functionality to create the output directory, generate timestamped filenames, write standard EXIF/GPS and GeoTIFF tags using tifffile based on AppState GeoInfo, manage automatic cleanup of old recordings based on configuration, and performs saving asynchronously in a dedicated thread. This version attempts to replicate the tag structure of a reference TIFF file using standard TIFF/EXIF/GPS tags, passing byte-based tags via extratags. Dependencies: - numpy - tifffile - pyproj (optional, improves accuracy of GeoTIFF transform) - rasterio (optional, only if needed for CRS object creation or fallback) """ # Standard library imports import logging import os import datetime import math import sys import threading # For dedicated writer thread and shutdown event import queue # For internal queue from pathlib import Path from typing import Dict, Any, Optional, Tuple, List # Third-party imports import numpy as np # Attempt to import required libraries try: import tifffile _tifffile_available = True # Define standard TIFF tag codes we might use IMAGE_DESCRIPTION_TAG = 270 # ASCII MAKE_TAG = 271 # ASCII MODEL_TAG = 272 # ASCII SOFTWARE_TAG = 305 # ASCII ARTIST_TAG = 315 # ASCII COPYRIGHT_TAG = 33432 # ASCII ORIENTATION_TAG = 274 # SHORT X_RESOLUTION_TAG = 282 # RATIONAL Y_RESOLUTION_TAG = 283 # RATIONAL RESOLUTION_UNIT_TAG = 296 # SHORT DOCUMENT_NAME_TAG = 269 # ASCII EXIF_IFD_TAG = 34665 # LONG (Pointer) GPS_IFD_TAG = 34853 # LONG (Pointer) # Define EXIF specific tags (relative to EXIF IFD) EXIF_VERSION_TAG = 36864 # UNDEFINED (4 bytes) USER_COMMENT_TAG = 37510 # UNDEFINED (Variable bytes, starts with encoding) # Define GeoTIFF specific tags MODEL_TRANSFORMATION_TAG = 33922 # DOUBLE (16) GEO_KEY_DIRECTORY_TAG = 34735 # SHORT (Variable * 4) except ImportError: tifffile = None _tifffile_available = False logging.error( "[ImageRecorder Init] 'tifffile' library not found. " "Cannot save TIFF files. Please install it (`pip install tifffile`)." ) # Define tags as None if library missing to avoid NameErrors later IMAGE_DESCRIPTION_TAG = MAKE_TAG = MODEL_TAG = SOFTWARE_TAG = None ARTIST_TAG = COPYRIGHT_TAG = EXIF_IFD_TAG = GPS_IFD_TAG = None EXIF_VERSION_TAG = USER_COMMENT_TAG = None MODEL_TRANSFORMATION_TAG = GEO_KEY_DIRECTORY_TAG = None ORIENTATION_TAG = X_RESOLUTION_TAG = Y_RESOLUTION_TAG = RESOLUTION_UNIT_TAG = None DOCUMENT_NAME_TAG = None # Optional: rasterio (only for potential CRS object help if needed) try: import rasterio from rasterio.transform import Affine # Keep Affine if calc helper returns it from rasterio.errors import CRSError _rasterio_available = True except ImportError: rasterio = None Affine = None CRSError = None _rasterio_available = False logging.warning( "[ImageRecorder Init] 'rasterio' not found. CRS validation limited." ) # Optional: pyproj for better transform accuracy try: import pyproj _pyproj_available = True except ImportError: pyproj = None _pyproj_available = False logging.warning( "[ImageRecorder Init] 'pyproj' not found. Transform accuracy might be reduced." ) # Local application imports from app_state import AppState # Requires access to app state for config/flags import config # Requires access to config for paths/limits class ImageRecorder: """Handles recording SAR images to GeoTIFF files asynchronously.""" def __init__(self, app_state: AppState): """ Initializes the ImageRecorder and starts the writer thread. Args: app_state (AppState): Reference to the shared application state. """ self._log_prefix = "[ImageRecorder]" logging.debug(f"{self._log_prefix} Initializing...") self._app_state: AppState = app_state # Recording directory setup self.recording_dir_name = config.DEFAULT_SAR_RECORDING_DIRECTORY try: # Determine base path based on execution context if getattr(sys, "frozen", False): # Check if running as bundled exe app_path = os.path.dirname(sys.executable) else: # Assumes ControlPanel.py (main script) is the entry point app_path = os.path.dirname(os.path.abspath(sys.argv[0])) self.recording_dir: Path = Path(app_path) / self.recording_dir_name logging.info( f"{self._log_prefix} Determined recording base path: {app_path}" ) except Exception as path_e: logging.exception( f"{self._log_prefix} Error determining script path. Using CWD." ) # Fallback to current working directory if path detection fails self.recording_dir: Path = Path.cwd() / self.recording_dir_name self._ensure_recording_dir_exists() # Check essential library for core functionality if not _tifffile_available: logging.error(f"{self._log_prefix} Tifffile missing. Recording disabled.") self._writer_thread = None self._recording_queue = None self._stop_event = None return # Stop initialization # Log warnings for missing optional libraries if not _pyproj_available: logging.warning( f"{self._log_prefix} Pyproj missing. GeoTIFF transform accuracy might be reduced." ) # --- Components for asynchronous saving --- self._recording_queue: queue.Queue = queue.Queue(maxsize=10) self._stop_event = threading.Event() self._writer_thread = threading.Thread( target=self._writer_loop, name="ImageWriterThread", daemon=True ) self._writer_thread.start() logging.info(f"{self._log_prefix} Writer thread started.") logging.debug(f"{self._log_prefix} Initialization complete.") def _ensure_recording_dir_exists(self): """Creates the recording directory if it doesn't exist.""" try: self.recording_dir.mkdir(parents=True, exist_ok=True) logging.info( f"{self._log_prefix} Recording directory ensured: {self.recording_dir}" ) except OSError as e: logging.error( f"{self._log_prefix} Failed to create recording directory " f"'{self.recording_dir}': {e}. Recording might fail." ) except Exception as e: logging.exception( f"{self._log_prefix} Unexpected error ensuring recording directory exists:" ) def record_sar_image( self, raw_image_data: np.ndarray, geo_info_radians: Dict[str, Any] ): """ Queues the raw SAR image data and GeoInfo for asynchronous saving. Args: raw_image_data (np.ndarray): The raw SAR image data (e.g., uint16). geo_info_radians (Dict[str, Any]): The georeferencing information with angles in radians. """ log_prefix = f"{self._log_prefix} QueueRecord" # Check if recording is enabled AND writer thread is running if not self._app_state.sar_recording_enabled: return if ( not self._writer_thread or not self._recording_queue or not _tifffile_available ): if _tifffile_available: # Log only if library is present but thread missing logging.warning( f"{log_prefix} Skipping queueing: Writer thread not active." ) return # Basic validation of inputs before queueing if raw_image_data is None or raw_image_data.size == 0: logging.warning( f"{log_prefix} Skipping queueing: No raw image data provided." ) return if not geo_info_radians or not geo_info_radians.get("valid", False): logging.warning( f"{log_prefix} Skipping queueing: Invalid GeoInfo provided." ) return # Put data onto the internal queue try: # Put a tuple containing COPIES of the data needed for saving item = (raw_image_data.copy(), geo_info_radians.copy()) self._recording_queue.put(item, block=False) logging.debug(f"{log_prefix} SAR image queued for recording.") except queue.Full: logging.warning( f"{log_prefix} Recording queue is full ({self._recording_queue.maxsize}). " "Discarding oldest SAR recording request." ) except Exception as e: logging.exception(f"{log_prefix} Error queueing SAR image for recording:") def _writer_loop(self): """Dedicated thread loop that processes the recording queue.""" log_prefix = f"{self._log_prefix} WriterLoop" logging.info(f"{log_prefix} Writer thread loop starting.") while not self._stop_event.is_set(): item = None try: item = self._recording_queue.get(block=True, timeout=1.0) except queue.Empty: continue except Exception as e: logging.exception(f"{log_prefix} Error getting from recording queue:") time.sleep(0.1) continue try: raw_data, geo_info = item log_prefix_item = f"{log_prefix} ItemProcess" logging.debug(f"{log_prefix_item} Dequeued SAR image for saving.") try: now = datetime.datetime.now() timestamp_str = now.strftime("%Y%m%d_%H%M%S_%f") # Microseconds dtype_bits = raw_data.dtype.itemsize * 8 filename = f"{timestamp_str}_SAR_geo_img{dtype_bits}.tif" output_path = self.recording_dir / filename except Exception as e: logging.exception( f"{log_prefix_item} Error generating filename: {e}" ) continue # Use the function to save TIFF with all tags using tifffile save_success = self._save_tiff_with_all_tags( raw_data, geo_info, output_path ) if save_success: logging.info( f"{log_prefix_item} Successfully saved TIFF with tags: {filename}" ) self._cleanup_old_recordings() else: logging.error( f"{log_prefix_item} Failed to save TIFF with tags: {filename}" ) except Exception as e: logging.exception(f"{log_prefix} Error processing recording item:") finally: # Ensure task_done is called even if processing fails if hasattr(self._recording_queue, "task_done"): try: self._recording_queue.task_done() except ValueError: pass # Ignore if already marked done logging.info(f"{log_prefix} Writer thread loop finished.") def shutdown(self): """Signals the writer thread to stop and waits for it to finish.""" log_prefix = f"{self._log_prefix} Shutdown" logging.debug(f"{log_prefix} Initiating shutdown...") if self._writer_thread and self._stop_event: logging.debug(f"{log_prefix} Signaling writer thread to stop...") self._stop_event.set() logging.debug(f"{log_prefix} Waiting for writer thread to join...") self._writer_thread.join(timeout=5.0) # Wait up to 5 seconds if self._writer_thread.is_alive(): logging.warning(f"{log_prefix} Writer thread did not join cleanly.") else: logging.info(f"{log_prefix} Writer thread joined successfully.") else: logging.debug(f"{log_prefix} Writer thread was not active.") logging.info(f"{log_prefix} Shutdown complete.") # --- GeoTIFF/EXIF Saving Logic --- def _calculate_affine_transform( self, geo_info_radians: Dict[str, Any] ) -> Optional[Tuple]: # Return tuple (a,b,c,d,e,f) """ Calculates the Affine transform parameters (a,b,c,d,e,f) using GeoInfo. Maps pixel *center* coordinates to CRS coordinates (EPSG:4326). """ log_prefix = f"{self._log_prefix} TransformCalc" try: # Extract necessary info scale_x = geo_info_radians["scale_x"] scale_y = geo_info_radians["scale_y"] orient_rad = geo_info_radians["orientation"] center_lon_rad = geo_info_radians["lon"] center_lat_rad = geo_info_radians["lat"] width = geo_info_radians["width_px"] height = geo_info_radians["height_px"] ref_x = geo_info_radians["ref_x"] ref_y = geo_info_radians["ref_y"] if not (scale_x > 0 and scale_y > 0 and width > 0 and height > 0): logging.error(f"{log_prefix} Invalid scale or dimensions in GeoInfo.") return None # Calculate rotation components cos_o = math.cos(orient_rad) sin_o = math.sin(orient_rad) # Calculate degrees per pixel (approximate) m_per_deg_lat = 111132.954 m_per_deg_lon = max(abs(111319.488 * math.cos(center_lat_rad)), 1e-3) deg_per_pix_x = scale_x / m_per_deg_lon deg_per_pix_y = scale_y / m_per_deg_lat # Calculate affine elements: [a, b, c] # [d, e, f] a = deg_per_pix_x * cos_o b = deg_per_pix_y * sin_o d = -deg_per_pix_x * sin_o e = -deg_per_pix_y * cos_o # Calculate geographic coordinates of the center of the top-left pixel (0, 0) center_lon_deg = math.degrees(center_lon_rad) center_lat_deg = math.degrees(center_lat_rad) # Vector from ref pixel center to TL pixel center (0,0) dx_pixel = -ref_x dy_pixel = -ref_y # Convert to meters (unrotated) dx_meters_unrot = dx_pixel * scale_x dy_meters_unrot = dy_pixel * scale_y # Rotate vector dx_meters_rot = dx_meters_unrot * cos_o - dy_meters_unrot * sin_o dy_meters_rot = dx_meters_unrot * sin_o + dy_meters_unrot * cos_o c = 0.0 # Top-left pixel center longitude f = 0.0 # Top-left pixel center latitude if not _pyproj_available: logging.warning( f"{log_prefix} Calculating TL corner without pyproj (less accurate)." ) # Estimate TL pixel center by applying rotated offset in degrees c = center_lon_deg + (dx_meters_rot / m_per_deg_lon) f = center_lat_deg + (dy_meters_rot / m_per_deg_lat) else: # Use pyproj for more accurate calculation geod = pyproj.Geod(ellps="WGS84") # Calculate distance and azimuth from center to TL pixel center vector dist_tl_center = math.hypot(dx_meters_rot, dy_meters_rot) azi_tl_center = math.degrees(math.atan2(dx_meters_rot, dy_meters_rot)) # Project from center to get TL pixel center coordinates tl_center_lon, tl_center_lat, _ = geod.fwd( center_lon_deg, center_lat_deg, azi_tl_center, dist_tl_center ) c = tl_center_lon f = tl_center_lat # Return the 6 affine parameters as a tuple transform_tuple = (a, b, c, d, e, f) logging.debug( f"{log_prefix} Calculated Affine transform tuple: {transform_tuple}" ) return transform_tuple except KeyError as ke: logging.error(f"{log_prefix} Missing required key in geo_info: {ke}") return None except Exception as e: logging.exception(f"{log_prefix} Error calculating Affine transform:") return None def _save_tiff_with_all_tags( self, raw_image_data: np.ndarray, geo_info_radians: Dict[str, Any], output_path: Path, ) -> bool: """ Saves the raw image data as a TIFF file including standard EXIF/GPS tags and GeoTIFF tags using tifffile.imwrite and structured metadata dictionary with tag names as keys, separating byte tags into extratags. """ log_prefix = f"{self._log_prefix} SaveAllTags" if not _tifffile_available: logging.error(f"{log_prefix} Cannot save TIFF: tifffile library missing.") return False metadata_dict = {} # Define outside try for logging in except extratags = [] # Define outside try for logging in except try: logging.debug(f"{log_prefix} Preparing all tags for {output_path}...") # Prepare tag dictionaries using helper functions # Standard tags are split into JSON-safe (name: value) and byte-based (code: value) standard_tags_normal, standard_tags_bytes = ( self._prepare_standard_tags_like_reference(geo_info_radians) ) standard_tags_normal = standard_tags_normal or {} standard_tags_bytes = standard_tags_bytes or {} # GPS tags prepared as name: value gps_tags = self._prepare_gps_tags_like_reference(geo_info_radians) or {} # GeoTIFF tags prepared as name: value geotiff_tags = self._prepare_geotiff_tags(geo_info_radians) or {} # --- Combine tags for tifffile.imwrite --- # 1. metadata dictionary (JSON serializable keys/values) metadata_dict = {} metadata_dict.update(standard_tags_normal) metadata_dict.update(geotiff_tags) if gps_tags: metadata_dict["GPS"] = gps_tags # Add GPS as nested dict # 2. extratags list for tags with byte values or needing specific formatting extratags = [] # Add byte tags (UserComment, ExifVersion) from standard tags logging.debug( f"{log_prefix} Formatting {len(standard_tags_bytes)} byte tags for extratags..." ) for code, value in standard_tags_bytes.items(): dtype_code, count, proc_val = self._get_tag_dtype_count_value( code, value ) if dtype_code is not None: logging.debug( f" - Adding Bytes Tag {code}: dtype={dtype_code}, count={count}..." ) extratags.append( (code, dtype_code, count, proc_val, True) ) # writeonce=True else: logging.warning( f"Tag {code}: Could not format byte tag for extratags." ) logging.debug( f"{log_prefix} Writing TIFF using imwrite with metadata " f"({len(metadata_dict)} main keys) and {len(extratags)} extratags..." ) output_path.parent.mkdir(parents=True, exist_ok=True) # Use tifffile.imwrite tifffile.imwrite( output_path, data=raw_image_data, photometric="minisblack", metadata=metadata_dict, # Pass dict with standard(non-byte), Geo, GPS extratags=extratags, # Pass list with (code, type, count, value) for byte tags ) logging.info( f"{log_prefix} Successfully wrote TIFF with metadata/extratags to {output_path}" ) return True except ImportError as imp_err: logging.error(f"{log_prefix} Missing library for TIFF writing: {imp_err}") return False except Exception as e: # Log prepared data for debugging metadata_content = str( metadata_dict if "metadata_dict" in locals() else "Not prepared" )[:500] extratags_content = str( extratags if "extratags" in locals() else "Not prepared" )[:500] logging.debug(f"Metadata prepared before error: {metadata_content}...") logging.debug(f"Extratags prepared before error: {extratags_content}...") logging.exception(f"{log_prefix} Error writing TIFF file to {output_path}:") try: # Attempt cleanup if output_path.exists(): output_path.unlink() except OSError: pass return False def _prepare_geotiff_tags( self, geo_info_radians: Dict[str, Any] ) -> Optional[Dict[str, Any]]: # Return Dict[str, Any] """Prepares GeoTIFF specific tags (name: value).""" log_prefix = f"{self._log_prefix} PrepareGeoTags" if not _tifffile_available: return None try: geotiff_tags = {} transform_tuple = self._calculate_affine_transform(geo_info_radians) # Use standard tag names recognised by tifffile/exiftool if transform_tuple: a, b, c, d, e, f = transform_tuple model_transform_matrix = [ [a, b, 0.0, c], [d, e, 0.0, f], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] geotiff_tags["ModelTransformationTag"] = tuple( item for sublist in model_transform_matrix for item in sublist ) else: logging.warning( f"{log_prefix} Could not calculate transform, ModelTransformationTag omitted." ) if GEO_KEY_DIRECTORY_TAG: # Check if constant is defined geokey_directory = [ 1, 1, 1, 3, 1024, 0, 1, 1, 2048, 0, 1, 4326, 2054, 0, 1, 9102, ] geotiff_tags["GeoKeyDirectoryTag"] = tuple(geokey_directory) logging.debug(f"{log_prefix} Prepared GeoTIFF tags (name: value).") return geotiff_tags except Exception as e: logging.exception(f"{log_prefix} Error preparing GeoTIFF tags: {e}") return None # Return two dictionaries: one for metadata (name:value), one for extratags (code:value_bytes) def _prepare_standard_tags_like_reference( self, geo_info_radians: Dict[str, Any] ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[int, Any]]]: """ Prepares standard TIFF/EXIF tags matching the reference file. Separates tags with standard JSON-serializable values (returned as name:value) from tags requiring specific byte formatting (returned as code:value). """ log_prefix = f"{self._log_prefix} PrepareStdRefTags" if not _tifffile_available: return None, None standard_tags_normal = {} # For metadata argument (name: value) standard_tags_bytes = {} # For extratags argument (code: value_bytes) try: # --- Tags for 'metadata' dictionary (JSON serializable values) --- standard_tags_normal["ImageDescription"] = "SAR" standard_tags_normal["Make"] = "RDR" standard_tags_normal["Model"] = "GBK" standard_tags_normal["Software"] = ( "ControlPanelRecorder_v1.0" # Use our name ) standard_tags_normal["Artist"] = "GRI" standard_tags_normal["Copyright"] = "LEO" standard_tags_normal["Orientation"] = 1 # TopLeft (int) standard_tags_normal["XResolution"] = (1, 1) # Rational tuple standard_tags_normal["YResolution"] = (1, 1) # Rational tuple standard_tags_normal["ResolutionUnit"] = 1 # None (int, matching reference) standard_tags_normal["DocumentName"] = "GRIFO Captured" # --- Tags for 'extratags' list (require specific type/bytes) --- # ExifVersion (Tag 36864, Type UNDEFINED, Count 4) if EXIF_VERSION_TAG: standard_tags_bytes[EXIF_VERSION_TAG] = b"0210" # UserComment (Tag 37510, Type UNDEFINED, Count variable) if USER_COMMENT_TAG: lat_deg = math.degrees(geo_info_radians.get("lat", 0.0)) lon_deg = math.degrees(geo_info_radians.get("lon", 0.0)) orient_deg = math.degrees(geo_info_radians.get("orientation", 0.0)) scale_x = geo_info_radians.get("scale_x", 0.0) scale_y = geo_info_radians.get("scale_y", 0.0) ref_x = geo_info_radians.get("ref_x", 0) ref_y = geo_info_radians.get("ref_y", 0) user_comment = ( f".GRIFO Captured image:." # Match reference format f"lat={lat_deg:.6f}.lon={lon_deg:.6f}." f"orientation={orient_deg:.6f}.scale={scale_x:.6f}." # Only one scale in ref? f"cx={ref_x}.cy={ref_y}." ) # Needs standard 8-byte prefix for UNDEFINED type standard_tags_bytes[USER_COMMENT_TAG] = ( b"ASCII\0\0\0" + user_comment.encode("ascii", errors="ignore") ) logging.debug( f"{log_prefix} Prepared {len(standard_tags_normal)} normal tags and {len(standard_tags_bytes)} byte tags." ) return standard_tags_normal, standard_tags_bytes except Exception as e: logging.exception(f"{log_prefix} Error preparing standard tags: {e}") return None, None def _prepare_gps_tags_like_reference( self, geo_info_radians: Dict[str, Any] ) -> Optional[Dict[str, Any]]: # Use names as keys for metadata['GPS'] """Prepares a dictionary of GPS IFD tags matching reference file (name: value).""" log_prefix = f"{self._log_prefix} PrepareGPSTagsRef" if not _tifffile_available: return None try: lat_deg = math.degrees(geo_info_radians.get("lat", 0.0)) lon_deg = math.degrees(geo_info_radians.get("lon", 0.0)) img_direction = math.degrees(geo_info_radians.get("orientation", 0.0)) def deg_to_dms_rational(deg_val): """Converts decimal degrees to TIFF RATIONAL format ((deg,1),(min,1),(sec,den)).""" if not math.isfinite(deg_val): return None deg_val_abs = abs(deg_val) degrees = math.floor(deg_val_abs) minutes_decimal = (deg_val_abs - degrees) * 60.0 minutes = math.floor(minutes_decimal) seconds_decimal = (minutes_decimal - minutes) * 60.0 sec_den = 1000000 # Microdegree precision sec_num = int(round(seconds_decimal * sec_den)) return ((int(degrees), 1), (int(minutes), 1), (sec_num, sec_den)) lat_dms = deg_to_dms_rational(lat_deg) lon_dms = deg_to_dms_rational(lon_deg) if lat_dms is None or lon_dms is None: logging.warning( f"{log_prefix} Could not convert lat/lon to DMS rational." ) return None gps_tags = {} # Use standard GPS tag names as keys for the nested dictionary gps_tags["GPSVersionID"] = (0, 0, 0, 2) # Match reference BYTE tuple gps_tags["GPSLatitudeRef"] = "N" if lat_deg >= 0 else "S" gps_tags["GPSLatitude"] = lat_dms gps_tags["GPSLongitudeRef"] = "E" if lon_deg >= 0 else "W" gps_tags["GPSLongitude"] = lon_dms gps_tags["GPSAltitudeRef"] = 0 # Above Sea Level gps_tags["GPSAltitude"] = (0, 1) # Rational 0/1 gps_tags["GPSTimeStamp"] = ( (0, 1), (0, 1), (0, 1), ) # Match reference 00:00:00 gps_tags["GPSSpeedRef"] = "K" # km/h gps_tags["GPSSpeed"] = (0, 1) # undef -> 0/1 gps_tags["GPSTrackRef"] = "T" # True North gps_tags["GPSTrack"] = (0, 1) # 0 degrees -> 0/1 gps_tags["GPSImgDirectionRef"] = "T" # True North img_direction_norm = img_direction % 360.0 gps_tags["GPSImgDirection"] = ( int(round(img_direction_norm * 1)), 1, ) # Match reference 0/1 ? logging.debug( f"{log_prefix} Prepared GPS tags like reference (name: value)." ) return gps_tags except Exception as e: logging.exception( f"{log_prefix} Error preparing GPS tags like reference: {e}" ) return None # Helper to determine tag type/count/value (Only needed for extratags) def _get_tag_dtype_count_value( self, code, value ) -> Tuple[Optional[int], Optional[int], Any]: """Helper to determine TIFF numeric dtype code, count, and processed value FOR EXTRATAGS.""" # Use standard TIFF numeric type codes directly # 1:BYTE, 2:ASCII, 3:SHORT, 4:LONG, 5:RATIONAL, 7:UNDEFINED, 12:DOUBLE dtype_code = None count = 1 processed_value = value try: # This function now likely only processes byte-based tags for extratags if isinstance(value, bytes): dtype_code = ( 7 if code in [USER_COMMENT_TAG, EXIF_VERSION_TAG] else 1 ) # UNDEFINED or BYTE count = len(value) processed_value = value # Already bytes # --- Keep other types in case we need to put more in extratags later --- elif isinstance(value, str): dtype_code = 2 processed_value = value.encode("ascii", errors="ignore") count = len(processed_value) + 1 elif isinstance(value, int): dtype_code = 4 if code in [GPS_IFD_TAG, EXIF_IFD_TAG] else 3 elif isinstance(value, float): dtype_code = 12 elif isinstance(value, tuple) or isinstance(value, list): # ... (rational, sequence handling - adjust if needed for extratags) ... if code in [2, 4, 6, 17]: # GPS Rationals flat_list = [] is_rational_format = True if not isinstance(value, tuple): is_rational_format = False else: for item in value: if not ( isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], int) and isinstance(item[1], int) ): is_rational_format = False break flat_list.extend(item) if is_rational_format: dtype_code = 5 count = len(value) processed_value = tuple(flat_list) else: logging.warning( f"Tag {code}: Invalid rational format for extratags: {value}" ) return None, None, None elif code == 0: # GPS Version dtype_code = 1 count = len(value) processed_value = tuple(value) elif code == GEO_KEY_DIRECTORY_TAG: dtype_code = 3 count = len(value) processed_value = tuple(value) elif code == MODEL_TRANSFORMATION_TAG: if len(value) == 16: dtype_code = 12 count = 16 processed_value = tuple(float(v) for v in value) else: logging.warning( f"Tag {code}: Invalid ModelTransform for extratags" ) return None, None, None else: logging.warning( f"Tag {code}: Unhandled sequence type for extratags: {type(value)}" ) return None, None, None else: logging.warning( f"Tag {code}: Unhandled value type for extratags: {type(value)}" ) return None, None, None if dtype_code is None: logging.warning(f"Tag {code}: Could not determine dtype for extratags.") return None, None, None return dtype_code, count, processed_value except Exception as e: logging.exception(f"Error determining dtype/count for extratag {code}: {e}") return None, None, None def _cleanup_old_recordings(self): """Removes the oldest SAR recordings if the count exceeds the configured limit.""" log_prefix = f"{self._log_prefix} Cleanup" max_files = config.DEFAULT_MAX_SAR_RECORDINGS if max_files <= 0: return logging.debug( f"{log_prefix} Checking '{self.recording_dir}' for recordings older than the newest {max_files}." ) try: if not self.recording_dir.is_dir(): return tif_files = [] pattern = "*_SAR_geo_img*.tif" for item in self.recording_dir.glob(pattern): if item.is_file(): try: mtime = item.stat().st_mtime tif_files.append((item, mtime)) except Exception as stat_e: logging.warning( f"{log_prefix} Could not stat file '{item.name}': {stat_e}" ) current_file_count = len(tif_files) if current_file_count <= max_files: return tif_files.sort(key=lambda x: x[1]) num_to_delete = current_file_count - max_files files_to_delete = tif_files[:num_to_delete] logging.debug( f"{log_prefix} Need to delete {num_to_delete} oldest recordings." ) deleted_count = 0 for file_path, _ in files_to_delete: try: file_path.unlink() logging.debug( f"{log_prefix} Deleted old recording: {file_path.name}" ) deleted_count += 1 except Exception as delete_e: logging.error( f"{log_prefix} Failed to delete file '{file_path.name}': {delete_e}" ) logging.debug( f"{log_prefix} Cleanup finished. Deleted {deleted_count}/{num_to_delete} files." ) except Exception as e: logging.exception(f"{log_prefix} Error during recording cleanup process:") # --- END OF FILE image_recorder.py ---