Reprojection Internals
This section describes the internal design of the nav.reproj package for
developers who need to extend or debug the reprojection and mosaicing
subsystem.
Module layout
src/nav/reproj/
__init__.py # Public API re-exports and __all__
bodies.py # BodyMosaic, BodyMosaicMergeStrategy, BodyReprojResult, BodyMosaicData
rings.py # RingMosaic, RingMosaicMergeStrategy, RingReprojResult, RingMosaicData
cartographic_model.py # create_cartographic_model, CartographicModelResult
ring_orbit_model.py # RingOrbitModel frozen dataclass
photometric_model.py # PhotometricModel protocol + implementations
_context_managers.py # _reduced_oops_precision
_serialization.py # Save/load helpers shared by all result dataclasses
Thread safety
RingMosaic.reproject() temporarily modifies oops global precision settings
via _reduced_oops_precision. Concurrent calls from different threads on the
same observation will interfere. BodyMosaic.reproject() and
create_cartographic_model() create Backplane objects from the provided
observation and are likewise not safe for concurrent use with the same
observation.
If you need to call reproject() from multiple threads, give each thread
its own obs instance.
Body mosaic storage
BodyMosaic uses a shifted circular buffer to handle longitude
wraparound without allocating the full 0 to 2π range.
The internal arrays (_img, _has_data, etc.) have shape
(n_lat, n_lon). A pair of integer offsets (_lat_min_bin,
_lon_min_bin) records which full-grid bin corresponds to row/column 0
of the buffer.
When new data arrives outside the current buffer extent, the buffer is
expanded by exact-fit reallocation (no padding). Latitude expansion
prepends or appends rows. Longitude expansion similarly extends the buffer;
if the data wraps around the 0/2π boundary, the column offset is
adjusted so that column 0 always maps to _lon_min_bin.
To retrieve data that spans the wraparound boundary, to_bounded() accepts
a lon_range where min > max (e.g., (5.9, 0.3)) meaning from
5.9 rad through 2π to 0.3 rad. Internally _extract_region builds
the column index list by concatenating the two disjoint ranges.
Ring sparse storage
RingMosaic stores only longitude columns that contain at least one valid
pixel. The _sparse_lon_mask boolean array (length n_full_lon) marks
which full-grid longitude bins are present. The data arrays have shape
(n_rad, n_sparse_lon) where n_sparse_lon equals the number of
True entries in _sparse_lon_mask.
When add() receives a RingReprojResult, it:
Identifies new longitude columns not yet in the sparse store.
Inserts those columns into all data arrays using a single
np.insert(..., axis=1)call per array to avoid repeated reallocations.Updates
_sparse_lon_mask.Applies the
RingMosaicMergeStrategyto resolve conflicts on existing columns.If at least one valid longitude column was present, appends
repro.image_nameto_contributing_image_namesand increments_image_count(socontributing_image_names[k]matches pixels tagged withimage_number == k).
The always-sparse design means that reproject() always returns a
RingReprojResult with only the valid longitude
columns populated. There is no compress_longitude flag; sparsity is the
invariant.
Ring radius and longitude semantics
The interpretation of RingMosaic.radius_inner / radius_outer (and the
matching fields on RingReprojResult and
RingMosaicData) depends on whether the mosaic was
constructed with an orbit_model:
orbit_model is None: longitudes are inertial J2000; radii are absolute km.orbit_modelis set: longitudes are co-rotating in that model’s frame; radii are signed offsets in km from the orbital radius at each (longitude, time) — i.e. fromorbit_model.radius_at_longitude(inertial_lon, et). This makes an eccentric ring appear as a straight line in the reprojection.
Implementation notes:
_reproject_innerevaluatesrad_bins_act = rad_bins * rad_res + radius_inner + model_r(inertial_lon, midtime)for each (row, column) when an orbit model is set, soradius_innerenters as a per-pixel offset added to the per-column orbital radius.The radius filter on
bp_radiusfirst computes per-pixel offsetsbp_radius_filter = bp_radius - model_r(inertial_lon, midtime)so the[radius_inner, radius_outer]test compares offsets to offsets.RingMosaic.reproject()rejects a per-callorbit_modelthat differs from the constructor’s, because radius semantics are tied to that choice.RingMosaic.add()validates that the reprojection’sorbit_modelis value-equal to the mosaic’s (RingOrbitModelis a frozen dataclass with auto-generated__eq__, so a model rebuilt from disk compares equal to the in-memory instance) and thatphotometric_model_namematches; mismatches raiseValueError.
dtype propagation
Each BodyMosaic and RingMosaic instance holds two authoritative dtype
attributes set at construction:
_image_dtype(defaultnp.float64) — dtype for reprojected brightnessimgarrays._metadata_dtype(defaultnp.float32) — dtype for geometry arrays on the reprojection grid (resolution,eff_resolution,phase,emission,incidence). Detector backplane samples feeding those quantities (for example incidence, emission, phase, latitude, longitude) are cast with.astype(self._metadata_dtype)insidereproject()before binning and masking; they are not applied to observation epochtime.
These propagate through the pipeline as follows:
_allocate/_expand_lat/_expand_lon_implallocate internal arrays using these dtypes directly (mosaic_timebuffers usefloat64, not_metadata_dtype).reproject()builds per-pixelimgat_image_dtypeand the geometry fields above at_metadata_dtype. ScalartimeonBodyReprojResultis a Pythonfloat(IEEE double); ring/body mosaictimegrids arenumpy.float64arrays regardless ofmetadata_dtype.Every
BodyReprojResultandBodyMosaicData(and ring equivalents) carries explicitimage_dtypeandmetadata_dtypefields describing the stored image and geometry dtypes;timeis never governed bymetadata_dtype. That contract is self-describing and survives a save/load round-trip.
image_number is always uint16 regardless of the dtype kwargs, capping a single mosaic at
65,535 contributing images. add() raises OverflowError when that
limit is exceeded.
Serialization
The _serialization module provides the format helpers used by all four
dataclass save() / load() methods. It is a private module (not
exported from __init__.py).
Path arguments may be str, pathlib.Path, or
filecache.FCPath. Each is normalized to FCPath on entry. Writes
resolve a local path with filecache.FCPath.get_local_path(), write with
NumPy or Astropy, then call filecache.FCPath.upload(). Reads resolve a
local path the same way (retrieving remote objects into the cache when needed)
before loading.
Supported formats
- npz
np.savez/np.savez_compressed. EachMaskedArrayis split into two npz entries:<name>__data(the underlying array at its declared dtype) and<name>__mask(abool_array). Tuples of length 2 of numeric types are stored as 1-D length-2 arrays. Tuples of strings (e.g.contributing_image_names) are stored as a 1-D Unicode string array. Strings, dtype names, and scalar floats/ints are stored as 0-D unicode or numeric arrays.- fits
astropy.io.fits. Scalar metadata (strings, numbers, dtype names) go into the PrimaryHDU header. Each array occupies a separate ImageHDU withEXTNAME = <FIELDNAME>; masks are stored as a companion ImageHDU withEXTNAME = <FIELDNAME>_MASK(uint8, 0 = valid). Tuple-of-string fields (contributing_image_names) are stored as a 1-Duint8ImageHDU of UTF-8 bytes withNUL(\\0) separators between entries (empty tuple → length-0 array).
Format inference
When format_=None (the default), the format is inferred from the file
extension:
.npz→'npz'.fits,.fit,.fits.gz,.fz→'fits'
An explicit format_='npz' or format_='fits' keyword overrides inference.
kind / version scheme
Every file includes two sentinel values:
__kind__— a string identifying the dataclass (e.g.'BodyMosaicData').load()raisesValueErrorwhen this does not match the expected kind.__version__— an integer (currently1). Reserved for future schema migrations.
To add a field in a future version: bump __version__ to 2, write the
new field in save(), and handle version == 1 (missing field) in
load() by supplying a sensible default.
Load-time dtype verification
After reconstructing the dataclass in load(), verify_dtype checks:
The
imgarray dtype matches the file’s declaredimage_dtype.Each metadata array (
resolution,eff_resolution,phase,emission,incidence, and for ringsmean_radial_*etc.) dtype matchesmetadata_dtype.The
timearray (mosaic data only) isfloat64.image_numberisuint16.All mask arrays are
bool_.
A ValueError is raised on the first mismatch, naming the offending field
and both the expected and actual dtypes. This catches files produced by
external tools that may have coerced dtypes on write.
RingOrbitModel serialization
RingOrbitModel is not a plain array; it is serialized flat via
orbit_model_to_dict / orbit_model_from_dict:
In npz: fields are stored as
orbit_model__<field>entries.In FITS: stored as
ORBIT_MODEL__<FIELD>header cards.When
orbit_model=None, a singleis_none=Truesentinel is written.
Photometric models
The PhotometricModel protocol requires
a single method:
def correct(
self,
data: NDArrayFloatType,
*,
incidence: NDArrayFloatType,
emission: NDArrayFloatType,
phase: NDArrayFloatType,
) -> NDArrayFloatType: ...
All three angle arrays are in radians. The correction is applied during
reproject() after pixel lookup, before writing to the reprojection result.
Passing photometric_model=None (the default) bypasses the correction.
Implementations provided:
LambertModel: divides bycos(incidence), clamped at a minimum threshold to avoid division by near-zero values.LommelSeeligerModel: divides bycos(incidence) / (cos(incidence) + cos(emission)).MinnaertModel: applies the Minnaert lawcos(incidence)^k * cos(emission)^(k-1)for a user-specified exponentk.
Context managers
One context manager in _context_managers.py ensures global state is
restored even if an exception occurs:
_reduced_oops_precision(dlt=1): setsoops.config.PATH_PHOTONSandSURFACE_PHOTONSdelta-time precision todlt. Restores both on exit.
Adding a new photometric model
Implement the PhotometricModel
protocol:
from nav.reproj.photometric_model import PhotometricModel
from nav.support.types import NDArrayFloatType
import numpy as np
class MyModel:
name = 'my_model'
def correct(
self,
data: NDArrayFloatType,
*,
incidence: NDArrayFloatType,
emission: NDArrayFloatType,
phase: NDArrayFloatType,
) -> NDArrayFloatType:
# custom correction logic
return data / np.cos(incidence)
Pass the instance to BodyMosaic or RingMosaic via the
photometric_model parameter.
Cartographic model projection
create_cartographic_model() inverts the
reprojection: for each pixel in the observation, the backplane is used to
obtain the lat/lon on the body surface, and the mosaic is sampled at that
position via bilinear interpolation (scipy.ndimage.map_coordinates with
order=1).
Longitude wraparound is handled by the formula:
col = ((bp_longitude - lon_min) % (2 * pi)) / lon_resolution
This is correct for both non-wrapping and wrapping lon_range values.
Command-line layer
The command-line tools are composed of three layers:
- Entry-point scripts (
src/main/) nav_mosaic.py—main()dispatches on the first positional argument (ringsorbody) and calls_run_rings/_run_body.rings_mainandbody_mainare thin wrappers that prepend the subcommand tosys.argvand callmain.nav_mosaic_cloud_tasks.py— Cloud Tasks worker that runs the reprojection pass only. The mode ('rings'or'body') is read from each task’stask_data['mode']field, so a single worker process can handle a queue that mixes ring and body tasks. The worker’s CLI parser is minimal: it exposes only--config-fileand--nav-results-rootand does not registeradd_ring_args/add_body_args. Every other parameter (output directory, format, mosaic geometry, body/planet selection, etc.) is read directly from each task’stask_data['arguments']dict.process_taskcalls the samereproj_clihelpers (build_*_mosaic,per_image_output_path,load_offset_if_any,apply_offset_to_obs,reproject_one_*) as the local driver. Mosaic combination is not performed here; runnav_mosaic <mode> <dataset_name> --skip-reprojectafter the queue drains (note thatnav_mosaic.pyrequires both the mode and the<dataset_name>positional arguments).nav_mosaic_display.py— same pattern for the display tools.
- Shared CLI helpers (
src/reproj_cli/) This top-level package (sibling of
nav/,backplanes/,pds4/) contains all the reusable CLI logic:args.py—add_common_env_args,add_common_output_args,add_ring_args,add_body_args,add_display_args. Adding a new command-line option to rings or body mode only requires editing the correspondingadd_*_argsfunction here.factories.py—build_ring_mosaic(args)/build_body_mosaic(args)translate parsedargparse.NamespaceintoRingMosaic/BodyMosaicinstances. Add a new constructor parameter here when the underlying classes gain a new option.paths.py—per_image_output_path/mosaic_output_pathdefine the output-file naming convention; pass-1 image logs go under<output-dir>/logs/(seenav_mosaic._reproject_image_log_handlers).offsets.py—load_offset_if_anyreads the_metadata.jsonfile written bynav_offsetand returns(dv, du)whenstatus == 'success'.apply_offset_to_obswraps the result inoops.fov.OffsetFOV. This mirrors the same pattern used insrc/backplanes/backplanes.py.reproject.py—reproject_one_body/reproject_one_ringthin wrappers that translate ring-specific CLI args (zoom, longitude range, radius range, margin) into keyword arguments forRingMosaic.reproject()/BodyMosaic.reproject(), including the per-imageimage_namestring (from the CLI or fromargs.image_name).
- Dataset enumeration
Both
nav_mosaic.pyand the existingnav_offset.py/nav_backplanes.pyscripts enumerate images viaDataSet.yield_image_files_from_arguments(). The dataset class is instantiated fromDATASET_NAME = sys.argv[1]viadataset_name_to_class(), which also providesadd_selection_arguments()to add dataset-specific filtering flags to the parser.
Two-pass workflow
The reprojection pass loops over
DATASET.yield_image_files_from_arguments(args) and for each ImageFile:
Compute
per_image_output_path(output_dir, prefix, image_file, fmt=…, subject_name=mosaic.body_name)(body or planet name in the filename;fmtandsubject_nameare keyword-only).Skip if the file exists and
--overwriteis not set.Open
IMAGE_LOGGERhandlers writing to<output-dir>/logs/….Load the observation via
obs_class.from_file(image_path).Optionally apply a navigation offset via
load_offset_if_any+apply_offset_to_obs.Call
reproject_one_body/reproject_one_ringwith the computedimage_name(file stem or--image-name).Save the
BodyReprojResult/RingReprojResultto disk.
The mosaic pass then iterates the same list a second time, loads each existing
reprojection file, calls mosaic.add() (for body mode, passing
resolution_threshold, copy_slop, and the body --max-incidence /
--max-emission / --max-resolution limits explicitly so accumulation
matches the CLI even when reprojection was skipped), and saves the final
BodyMosaicData / RingMosaicData via mosaic_output_path (same
subject_name=mosaic.body_name convention).
Display layer
Package layout — src/nav/ui/mosaic_viewer/
tiled_image_widget.py—TiledImageWidget. A generalizedQAbstractScrollAreathat:
Renders only the visible viewport tiles on each paint event (tile-granular repaint), keeping memory usage independent of zoom level.
Supports independent X and Y zoom factors.
Handles Shift+scroll (X only), Ctrl+scroll (Y only), both-axes scroll.
Rubber-band zoom (Shift+left-drag).
Left-drag pan.
Green horizontal/vertical line overlays (show-radii / show-parallels / show-meridians) via
set_show_rows/set_show_cols.Optional axis-tick overlays (longitude at bottom, radius/latitude at left) via
set_axis_tick_options.
y_flip=Truefor ring mosaics (array row 0 = inner radius, displayed at bottom);y_flip=Falsefor body mosaics (row 0 = top of display).Uses
nav.ui.common.apply_linear_gamma_stretch()for image contrast, ensuring a consistentdata ** gammaconvention across all viewers.Does not use
nav.ui.common.ZoomPanController— that helper assumes a pre-scaledQLabelinside aQScrollArea, which is incompatible with tile-paint independent X/Y zoom.
common.py—load_ring_file()/load_body_file(). Peeks at the__kind__header in an npz or FITS file, then delegates to the appropriate*.load()classmethod and normalises the result into aRingDisplayData/BodyDisplayDatadataclass ready for the window.
ring_window.py—RingMosaicWindow. Includes: stretch controls (vianav.ui.common.build_stretch_controls()), color-by panel (radial/angular resolution, phase, emission, image number; ephemeris-only options are omitted), EW-profile Matplotlib panel, radial-slice Matplotlib panel (right-click on mosaic), show-radii overlay, longitude/radius axis ticks, Prev/Next file navigation, and Save-FOV.RingDisplayData.contributing_image_namesand per-longitude observation time feed the cursor Source image line (name and UTC time combined) for mosaics and single reprojections.
body_window.py—BodyMosaicWindow. Header row for latitude/longitude axis tick toggles, image area with parallels/meridians overlays in the sidebar, lower strip with stretch presets, log-style zoom controls, a four-column Cursor Info grid, and a Color By radio grid (resolution, effective resolution, phase, emission, incidence, image number when present). The cursor grid includes sub-solar and sub-observer longitude and latitude (degrees), resolved fromBodyDisplayDataper-image arrays populated byload_body_file(). The Source image line shows the contributing name (or file stem) together with the pixel observation time in UTC when available.
Adding a new display feature
If the feature requires a new per-column or per-pixel metadata array, extend the
RingDisplayData/BodyDisplayDatadataclass innav/ui/mosaic_viewer/common.pyand populate it inload_ring_file/load_body_file(including fields sourced fromRingMosaicData/BodyMosaicDatasuch ascontributing_image_names,sub_solar_*_per_image,sub_observer_*_per_image, etc.).If the feature needs a new image overlay (e.g. contour lines), implement it as a new signal-driven
set_*method onTiledImageWidgetand call it from the window.If the feature adds a new UI control (e.g. checkbox or slider), add it in the relevant window’s
_build_right_panel,_build_plot_panels/ header row, or_build_control_panelmethod and wire the callback.
Gamma stretch convention
All viewers use the ((clip - black) / (white - black)) ** gamma convention
implemented by nav.ui.common.apply_linear_gamma_stretch(). A gamma of
1.0 is linear; values below 1.0 brighten mid-tones (the common display
choice). This convention is now uniformly applied across
TiledImageWidget, manual_nav_dialog, nav_backplane_viewer, and
nav_create_simulated_image.