"""Software renderer for non-rectangular body-mosaic projections.
:func:`render_to_image` converts a (lon_deg, lat_deg, valid) coordinate grid
-- produced by :mod:`~nav.ui.mosaic_viewer.projections` -- into a
``QImage`` (Format_RGB888) using nearest-neighbour texture lookup, the same
contrast-stretch pipeline as the existing rectangular render path, and optional
per-pixel metadata tinting.
The function is intentionally stateless and side-effect-free so it can be
called from any paint method without lock concerns.
"""
import math
from typing import cast
import numpy as np
import numpy.ma as ma
from PyQt6.QtGui import QImage
from nav.ui.common import apply_linear_gamma_stretch
[docs]
def render_to_image(
image_ma: ma.MaskedArray,
*,
lon_deg: np.ndarray,
lat_deg: np.ndarray,
valid: np.ndarray,
lon_min_deg: float,
lat_min_deg: float,
d_lon_deg: float,
d_lat_deg: float,
lon_bin_to_dc: np.ndarray,
n_full_lon: int,
n_data_rows: int,
n_data_cols: int,
black: float,
white: float,
gamma: float,
color_tint: np.ndarray | None,
) -> QImage:
"""Render a mosaic texture onto an arbitrary projection grid.
Parameters:
image_ma: 2-D masked array (n_data_rows, n_data_cols) with the
photometrically-corrected mosaic data.
lon_deg: 2-D float64 array of longitudes (deg) for every output pixel,
in [0, 360). After binning to ``k``, any pixel with ``lon_bin_to_dc[k] == -1``
has no data column and is off-grid (no-data when ``valid`` is True).
lat_deg: 2-D float64 array of latitudes (deg), in [-90, 90]. Latitudes outside
``[lat_min_deg, lat_min_deg + n_data_rows * d_lat_deg)`` map to ``dr`` outside
``[0, n_data_rows)`` and are off-grid (no-data when ``valid`` is True).
valid: 2-D bool array; False pixels are painted black (off-projection
background). True pixels with no usable data (outside the file's
lat/lon extent or explicitly masked) are painted dark red.
lon_min_deg: Geographic minimum longitude of the data grid (deg).
lat_min_deg: Geographic minimum latitude of the data grid (deg).
d_lon_deg: Longitude resolution of the data grid (deg/column).
d_lat_deg: Latitude resolution of the data grid (deg/row).
lon_bin_to_dc: 1-D int32 array mapping full-circle longitude bin index
to data column index (-1 = absent).
n_full_lon: Length of ``lon_bin_to_dc``.
n_data_rows: Number of rows in ``image_ma``.
n_data_cols: Number of columns in ``image_ma``.
black: Stretch black point.
white: Stretch white point.
gamma: Stretch gamma (applied as power).
color_tint: Optional per-pixel RGB tint, shape
(n_data_rows, n_data_cols, 3), float32 in [0, 1]. None = greyscale.
Returns:
A QImage in Format_RGB888 matching the shape of ``lon_deg`` / ``lat_deg``.
Raises:
ValueError: If ``d_lon_deg`` or ``d_lat_deg`` is zero, if ``gamma`` is not
positive, if ``len(lon_bin_to_dc) != n_full_lon``, or if ``lon_deg``,
``lat_deg``, and ``valid`` do not share the same shape.
"""
if d_lon_deg == 0 or d_lat_deg == 0:
raise ValueError(
f'render_to_image requires non-zero d_lon_deg and d_lat_deg; '
f'got d_lon_deg={d_lon_deg!r}, d_lat_deg={d_lat_deg!r}'
)
if gamma <= 0:
raise ValueError(f'render_to_image requires gamma > 0; got gamma={gamma!r}')
if lon_deg.shape != lat_deg.shape or lon_deg.shape != valid.shape:
raise ValueError(
'render_to_image: lon_deg, lat_deg, and valid must have identical shapes; '
f'got lon_deg {lon_deg.shape}, lat_deg {lat_deg.shape}, valid {valid.shape}'
)
if len(lon_bin_to_dc) != n_full_lon:
raise ValueError(
f'render_to_image: len(lon_bin_to_dc) must equal n_full_lon; '
f'got len(lon_bin_to_dc)={len(lon_bin_to_dc)}, n_full_lon={n_full_lon}'
)
out_h, out_w = lon_deg.shape
# ------------------------------------------------------------------
# Map (lon_deg, lat_deg) -> (dc, dr) data indices
# ------------------------------------------------------------------
lon_r = np.deg2rad(lon_deg)
lon_res_rad = math.radians(d_lon_deg)
twopi = 2.0 * math.pi
lon_r_mod = np.mod(lon_r, twopi)
k = np.floor(np.minimum(lon_r_mod / lon_res_rad, float(n_full_lon) - 1e-9)).astype(np.int64)
k = np.clip(k, 0, n_full_lon - 1)
dc = lon_bin_to_dc[k] # data column, -1 if absent
dr = np.floor((lat_deg - lat_min_deg) / d_lat_deg).astype(np.int64)
inside = valid & (dc >= 0) & (dc < n_data_cols) & (dr >= 0) & (dr < n_data_rows)
# ------------------------------------------------------------------
# Sample texture
# ------------------------------------------------------------------
tile_data = np.zeros((out_h, out_w), dtype=np.float32)
tile_mask = np.ones((out_h, out_w), dtype=bool)
if np.any(inside):
dr_v = np.clip(dr[inside], 0, n_data_rows - 1)
dc_v = np.clip(dc[inside], 0, n_data_cols - 1)
sub = image_ma[dr_v, dc_v]
tile_data[inside] = np.asarray(np.nan_to_num(sub.filled(0.0), nan=0.0), dtype=np.float32)
tile_mask[inside] = ma.getmaskarray(sub)
# ------------------------------------------------------------------
# Apply stretch
# ------------------------------------------------------------------
stretched = apply_linear_gamma_stretch(tile_data, black=black, white=white, gamma=gamma).astype(
np.float32
)
gray = (stretched * 255.0).astype(np.uint8)
# ------------------------------------------------------------------
# Build RGB with optional per-pixel tint
# ------------------------------------------------------------------
if color_tint is not None and np.any(inside):
dr_c = np.clip(dr, 0, n_data_rows - 1)
dc_c = np.clip(dc, 0, n_data_cols - 1)
# Default: neutral tint (1.0) everywhere
tint = np.ones((out_h, out_w, 3), dtype=np.float32)
tint[inside] = color_tint[dr_c[inside], dc_c[inside]]
gray_f = gray[:, :, np.newaxis].astype(np.float32)
rgb = np.clip(gray_f * tint, 0, 255).astype(np.uint8)
else:
rgb = np.stack([gray, gray, gray], axis=2)
# Background (off-projection) pixels -> black
background = ~valid
if np.any(background):
rgb[background, 0] = 0
rgb[background, 1] = 0
rgb[background, 2] = 0
# Any valid projection pixel with no usable data (explicitly masked or
# simply outside the file's lat/lon extent) -> dark red.
no_data = valid & (~inside | tile_mask)
if np.any(no_data):
rgb[no_data, 0] = 180
rgb[no_data, 1] = 0
rgb[no_data, 2] = 0
# ------------------------------------------------------------------
# Build QImage from a persistent buffer (constructor keeps a pointer; do not
# pass a temporary ``tobytes()`` result without pinning the backing bytes).
# ------------------------------------------------------------------
rgb_c = np.ascontiguousarray(rgb, dtype=np.uint8)
buf = bytearray(rgb_c.tobytes())
qimg = QImage(
cast(bytes, buf),
out_w,
out_h,
3 * out_w,
QImage.Format.Format_RGB888,
)
qimg._buf = buf # type: ignore[attr-defined] # pin buffer lifetime for QImage ctor
return qimg