843 lines
36 KiB
Python
843 lines
36 KiB
Python
# --- 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 controlpanel.app_state import AppState # Requires access to app state for config/flags
|
|
from controlpanel 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 ---
|