# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Spatial calibration utilities for axis labels and scale bars.
This module provides helper functions to select readable distance units and
format axis labels for display based on the image physical extent.
"""
from __future__ import annotations
from dataclasses import dataclass
[docs]
@dataclass(frozen=True)
class DistanceUnit:
"""
Distance unit definition.
:param name: Display name for the unit (ASCII).
:type name: str
:param meters_per_unit: Scale factor to convert unit values to meters.
:type meters_per_unit: float
"""
name: str
meters_per_unit: float
_UNITS: tuple[DistanceUnit, ...] = (
DistanceUnit('nm', 1.0e-9),
DistanceUnit('um', 1.0e-6),
DistanceUnit('mm', 1.0e-3),
DistanceUnit('cm', 1.0e-2),
DistanceUnit('m', 1.0),
)
[docs]
def select_distance_unit(max_extent_m: float) -> DistanceUnit:
"""
Select a unit so the max extent is readable (1 <= value < 1000).
:param max_extent_m: Maximum extent in meters.
:type max_extent_m: float
:return: Selected distance unit.
:rtype: DistanceUnit
"""
if max_extent_m <= 0:
return _UNITS[1]
for unit in reversed(_UNITS):
value = max_extent_m / unit.meters_per_unit
if value >= 1.0:
return unit
return _UNITS[0]
[docs]
def nice_value(value: float) -> float:
"""
Round a value to a 1-2-5 * 10^n sequence.
:param value: Input value.
:type value: float
:return: Rounded value.
:rtype: float
"""
if value <= 0:
return value
exp = int(_floor_log10(value))
base = 10.0**exp
scaled = value / base
candidates = [1.0, 2.0, 5.0, 10.0]
eligible = [c for c in candidates if c <= scaled]
if not eligible:
return candidates[0] * base
return max(eligible) * base
[docs]
def _log10(value: float) -> float:
"""Compute base-10 log without importing math for minimal dependencies."""
import math
return math.log10(value)
[docs]
def _floor_log10(value: float) -> float:
"""Compute floor(log10(value))."""
import math
return math.floor(math.log10(value))
[docs]
def axis_decimal_places(pixel_size_m: float, meters_per_unit: float, lims: tuple[float, float]) -> int:
"""
Determine decimal places for axis labels based on current view scale.
:param pixel_size_m: Pixel size in meters.
:type pixel_size_m: float
:param meters_per_unit: Unit scale in meters.
:type meters_per_unit: float
:param lims: Axis limits in pixel units.
:type lims: tuple[float, float]
:return: Number of decimal places.
:rtype: int
"""
import math
extent_px = abs(lims[1] - lims[0])
if extent_px <= 0:
return 2
extent_units = (extent_px * pixel_size_m) / meters_per_unit
if extent_units <= 0:
return 2
step_units = extent_units / 5.0
if step_units <= 0:
return 2
return max(0, min(6, int(-math.floor(math.log10(step_units))) + 1))