Source code for flametrack.analysis.ir_analysis

from __future__ import annotations

import logging
import re
import warnings
from collections.abc import Sequence
from datetime import datetime
from typing import Any

import cv2
import numpy as np
from numpy.typing import NDArray

_PAT_DATE = re.compile(r"RecDate=(\d+\.\d+\.\d+)")
_PAT_TIME = re.compile(r"RecTime=(\d+:\d+:\d+)")
_PAT_MS = re.compile(r"ms=([\d.]+)")


[docs] def compute_remap_from_homography( homography: NDArray[np.float32] | NDArray[np.float64], width: int, height: int, ) -> tuple[NDArray[np.float32], NDArray[np.float32]]: """ Compute pixelwise remap grids from a homography. Args: homography (numpy.ndarray): 3×3 homography mapping output → input coordinates. width (int): Target image width in pixels. height (int): Target image height in pixels. Returns: tuple[numpy.ndarray, numpy.ndarray]: Two arrays ``(src_x, src_y)`` with shape ``(height, width)``, dtype ``float32`` suitable for ``cv2.remap``. """ # Pixelzentren (x+0.5, y+0.5) map_x_f = np.arange(width, dtype=np.float32) + 0.5 map_y_f = np.arange(height, dtype=np.float32) + 0.5 map_x, map_y = np.meshgrid(map_x_f, map_y_f, indexing="xy") ones = np.ones_like(map_x, dtype=np.float32) target_coords = np.stack([map_x, map_y, ones], axis=-1).reshape(-1, 3).T # (3, N) homography_f: NDArray[np.float32] = np.asarray(homography, dtype=np.float32) source_coords = homography_f @ target_coords source_coords /= source_coords[2, :] # normalize src_x = source_coords[0, :].reshape((height, width)).astype(np.float32, copy=False) src_y = source_coords[1, :].reshape((height, width)).astype(np.float32, copy=False) return src_x, src_y
[docs] def read_csv_timestamp(path: str) -> datetime | None: """Return the recording datetime from a CSV header, or None if absent. Uses only ``RecDate`` and ``RecTime`` (second-level resolution). The ``ms=`` field is intentionally ignored here because it is a cumulative elapsed-milliseconds counter that resets at every IRB container boundary and therefore cannot be used for sub-second refinement without knowing the full preceding ms history. Elapsed-time construction (including sub-second precision and container-reset stitching) is handled separately by :meth:`IrData.get_timestamps`. """ with open(path, encoding="latin-1", errors="replace") as f: header = f.read(512) d, t = _PAT_DATE.search(header), _PAT_TIME.search(header) if not (d and t): return None return datetime.strptime(f"{d.group(1)} {t.group(1)}", "%d.%m.%Y %H:%M:%S")
[docs] def read_ir_data(filename: str) -> NDArray[np.float64]: """ Read raw IR data from a CSV-like ASCII export. The file is scanned until a line ``[Data]`` is found; subsequent lines are parsed using ``;`` as delimiter and a comma-to-dot decimal replacement. Args: filename (str): Path to the IR data file. Returns: numpy.ndarray: 2D array of IR values (dtype ``float64``). Raises: ValueError: If no ``[Data]`` section is found in the file. """ with open(filename, encoding="latin-1") as f: line = f.readline() while line: if line.startswith("[Data]"): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") arr = np.genfromtxt( (line.replace(",", ".")[:-2] for line in f.readlines()), delimiter=";", invalid_raise=False, ) if caught: details = "; ".join(str(w.message) for w in caught) logging.warning("Malformed lines in %s: %s", filename, details) return np.asarray(arr, dtype=np.float64) line = f.readline() raise ValueError("No data found in file, check file format!")
[docs] def get_dewarp_parameters( corners: NDArray[np.float32] | Sequence[tuple[float, float]], target_pixels_width: int | None = None, target_pixels_height: int | None = None, target_ratio: float | None = None, *, plate_width_m: float | None = None, plate_height_m: float | None = None, pixels_per_millimeter: int = 1, ) -> dict[str, Any]: """ Calculate homography and target geometry for dewarping. You can either pass physical plate dimensions (``plate_width_m``, ``plate_height_m``) plus a pixel density, or infer target geometry from the selected corners and a desired aspect ratio. Args: corners (numpy.ndarray | Sequence[tuple[float, float]]): Four corner points in pixel coordinates, ordered clockwise starting at top-left. target_pixels_width (int, optional): Target width in pixels. If omitted, it will be derived from ``target_ratio`` and the measured corner distances. target_pixels_height (int, optional): Target height in pixels. If omitted, it will be derived from ``target_ratio`` and the measured corner distances. target_ratio (float, optional): Desired aspect ratio ``height / width``. Required if target size is not specified and no physical plate size is provided. plate_width_m (float, optional): Physical plate width in meters. Used with ``pixels_per_millimeter`` to derive target size if provided with ``plate_height_m``. plate_height_m (float, optional): Physical plate height in meters. Used with ``pixels_per_millimeter`` to derive target size if provided with ``plate_width_m``. pixels_per_millimeter (int, optional): Pixel density (px/mm) used when physical dimensions are given. Default is 1. Returns: dict[str, Any]: Dictionary with: - ``transformation_matrix`` (numpy.ndarray): 3×3 homography (float32). - ``target_pixels_width`` (int): Target width in pixels. - ``target_pixels_height`` (int): Target height in pixels. - ``target_ratio`` (float): ``height / width`` of the target. Raises: ValueError: If neither physical dimensions nor a target ratio are provided. Notes: Current conversion multiplies meter values by ``pixels_per_millimeter``. For strict unit consistency, consider using millimeters or ``pixels_per_meter``. """ buffer = 1.1 source_corners: NDArray[np.float32] = np.asarray(corners, dtype=np.float32) # Falls echte Plattenmaße gegeben sind, direkt daraus Pixel ableiten if plate_width_m is not None and plate_height_m is not None: target_pixels_width = int(plate_width_m * pixels_per_millimeter) target_pixels_height = int(plate_height_m * pixels_per_millimeter) # Sonst versucht: aus Ecken + Ratio ab zuleiten if target_pixels_width is None or target_pixels_height is None: if target_ratio is None: raise ValueError("Either plate dimensions or target ratio must be provided") # grobe Abschätzung Breite/Höhe in Pixeln aus den Ecken max_width = max( float(source_corners[1][0] - source_corners[0][0]), float(source_corners[2][0] - source_corners[3][0]), ) max_height = max( float(source_corners[2][1] - source_corners[1][1]), float(source_corners[3][1] - source_corners[0][1]), ) target_pixels_height = int( max(max_height, max_width / float(target_ratio)) * buffer ) target_pixels_width = int(target_pixels_height * float(target_ratio)) tpw = int(target_pixels_width) # mypy-safe ints tph = int(target_pixels_height) target_corners = np.array( [ [0.0, 0.0], [float(tpw), 0.0], [float(tpw), float(tph)], [0.0, float(tph)], ], dtype=np.float32, ) transformation_matrix = cv2.getPerspectiveTransform(source_corners, target_corners) return { "transformation_matrix": np.asarray(transformation_matrix, dtype=np.float32), "target_pixels_width": tpw, "target_pixels_height": tph, # Beibehaltung deines bisherigen Verhältnisses (height/width): "target_ratio": float(tph) / float(tpw), }