import copy
import json
from functools import lru_cache
from typing import Any, cast
import numpy as np
from psfmodel import GaussianPSF
from scipy import ndimage
from starcat import Star
from nav.sim.sim_body import create_simulated_body
from nav.sim.sim_ring import render_ring
from nav.support.types import (
MutableStar,
NDArrayBoolType,
NDArrayFloatType,
NDArrayIntType,
)
@lru_cache(maxsize=1)
def _render_stars_cached(
size_v: int,
size_u: int,
stars_params_json: str,
offset_v: float,
offset_u: float,
) -> tuple[Any, ...]:
"""Internal cached function to compute star rendering."""
stars_params = json.loads(stars_params_json)
img = np.zeros((size_v, size_u), dtype=np.float64)
sim_star_list: list[MutableStar] = []
star_info: list[dict[str, Any]] = []
for i, star_params in enumerate(stars_params):
star = cast(MutableStar, Star())
star.unique_number = i + 1
star.catalog_name = str(star_params.get('catalog_name', 'SIM'))
star.pretty_name = str(star_params.get('name', f'SIM-{i + 1}'))
star.name = star.pretty_name
star.v = float(star_params.get('v', size_v / 2))
star.u = float(star_params.get('u', size_u / 2))
star.move_v = float(star_params.get('move_v', 0.0))
star.move_u = float(star_params.get('move_u', 0.0))
star.vmag = float(star_params.get('vmag', 8.0))
star.spectral_class = str(star_params.get('spectral_class', 'G2'))
star.temperature = Star.temperature_from_sclass(star.spectral_class)
star.temperature_faked = star.temperature is None
if star.temperature is None:
star.temperature = 5780.0
star.johnson_mag_v = star.vmag
bmv = Star.bmv_from_sclass(star.spectral_class or 'G2') or 0.63
star.johnson_mag_b = star.johnson_mag_v + bmv
star.johnson_mag_faked = False
star.ra_pm = 0.0
star.dec_pm = 0.0
star.conflicts = ''
star.psf_size = tuple(star_params.get('psf_size', (11, 11)))
star.dn = 2.512 ** -(star.vmag - 4.0)
sim_star_list.append(star)
star_offset_v = star.v + offset_v
star_offset_u = star.u + offset_u
v_int = int(star_offset_v)
u_int = int(star_offset_u)
v_frac = star_offset_v - v_int
u_frac = star_offset_u - u_int
psf_size_half_u = int(star.psf_size[1] + np.round(abs(star.move_u))) // 2
psf_size_half_v = int(star.psf_size[0] + np.round(abs(star.move_v))) // 2
max_move_steps = 1 # TODO configurable
move_gran = max(abs(star.move_u) / max_move_steps, abs(star.move_v) / max_move_steps)
move_gran = np.clip(move_gran, 0.1, 1.0)
sigma = star_params.get('psf_sigma', 3.0)
psf = GaussianPSF(sigma=sigma)
# Stars where any part of the PSF would be off the edge of the image are ignored.
# This is because PSF fitting will not work in these cases.
if (
u_int < psf_size_half_u
or u_int >= img.shape[1] - psf_size_half_u
or v_int < psf_size_half_v
or v_int >= img.shape[0] - psf_size_half_v
):
# Still collect info for hit-testing
star_info.append(
{
'name': star.name,
'center_v': star_offset_v,
'center_u': star_offset_u,
'sigma': sigma,
'psf_half_v': psf_size_half_v,
'psf_half_u': psf_size_half_u,
}
)
continue
# Evaluate PSF with scale=1.0 first to get unnormalized PSF
star_psf = psf.eval_rect(
(psf_size_half_v * 2 + 1, psf_size_half_u * 2 + 1),
offset=(v_frac, u_frac),
scale=1.0,
movement=(star.move_v, star.move_u),
movement_granularity=move_gran,
)
# Normalize PSF so peak is 1.0, then scale by magnitude
psf_max = np.max(star_psf)
if psf_max > 0:
star_psf = star_psf / psf_max
# Scale so that vmag=0 results in peak=1.0
# star.dn = 2.512^-(vmag - 4.0), so for vmag=0: star.dn = 2.512^4
# We want vmag=0 -> peak=1, so scale by star.dn / (2.512^4)
scale_factor = star.dn / (2.512**4.0)
star_psf = star_psf * scale_factor
img[
v_int - psf_size_half_v : v_int + psf_size_half_v + 1,
u_int - psf_size_half_u : u_int + psf_size_half_u + 1,
] += star_psf
star_info.append(
{
'name': star.name,
'center_v': star_offset_v,
'center_u': star_offset_u,
'sigma': sigma,
'psf_half_v': psf_size_half_v,
'psf_half_u': psf_size_half_u,
}
)
return (img, sim_star_list, star_info)
[docs]
def render_stars(
img: NDArrayFloatType,
stars_params: list[dict[str, Any]],
offset_v: float,
offset_u: float,
) -> tuple[NDArrayFloatType, list[MutableStar], list[dict[str, Any]]]:
"""Render stars into img. Returns (img, sim_star_list, star_render_info)."""
size_v, size_u = img.shape
stars_params_json = json.dumps(stars_params, sort_keys=True)
cached_img, cached_star_list, cached_star_info = _render_stars_cached(
size_v, size_u, stars_params_json, offset_v, offset_u
)
# Add cached stars to input image (don't overwrite background noise/stars)
img[:] = np.clip(img + cached_img, 0.0, 1.0)
return img, cached_star_list, cached_star_info
@lru_cache(maxsize=30)
def _render_body_shape_cached(
size_v: int,
size_u: int,
axis1: float,
axis2: float,
axis3: float,
rotation_z: float,
rotation_tilt: float,
illumination_angle: float,
phase_angle: float,
crater_fill: float,
crater_min_radius: float,
crater_max_radius: float,
crater_power_law_exponent: float,
crater_relief_scale: float,
anti_aliasing: float,
body_seed: int | None,
) -> NDArrayFloatType:
"""First layer cache: compute body shape at reference center (image center).
Caches body shapes based on all parameters except center_v/center_u.
Max size 30 allows caching up to 30 different body configurations.
"""
# Use image center as reference - we'll translate when positioning
ref_center_v = size_v / 2.0
ref_center_u = size_u / 2.0
sim_body = create_simulated_body(
size=(size_v, size_u),
center=(ref_center_v, ref_center_u),
axis1=axis1,
axis2=axis2,
axis3=axis3,
rotation_z=rotation_z,
rotation_tilt=rotation_tilt,
illumination_angle=illumination_angle,
phase_angle=phase_angle,
crater_fill=crater_fill,
crater_min_radius=crater_min_radius,
crater_max_radius=crater_max_radius,
crater_power_law_exponent=crater_power_law_exponent,
crater_relief_scale=crater_relief_scale,
anti_aliasing=anti_aliasing,
seed=body_seed,
)
return sim_body
@lru_cache(maxsize=1)
def _render_bodies_positioned_cached(
size_v: int,
size_u: int,
bodies_params_no_center_json: str,
centers_json: str,
offset_v: float,
offset_u: float,
seed: int | None,
) -> dict[str, Any]:
"""Second layer cache: position cached body shapes based on u,v coordinates.
Caches the final positioned result based on center_v/center_u.
Max size 1 means only the most recent positioning is cached.
"""
bodies_params_no_center = json.loads(bodies_params_no_center_json)
centers = json.loads(centers_json)
img = np.zeros((size_v, size_u), dtype=np.float64)
# Reconstruct full body params with centers for processing
body_models = []
for i, params_no_center in enumerate(bodies_params_no_center):
params = dict(params_no_center)
center_v, center_u = centers[i]
params['center_v'] = center_v
params['center_u'] = center_u
body_models.append(params)
for body_number, body_params in enumerate(body_models):
if 'range' in body_params:
body_params['range'] = float(body_params['range'])
else:
body_params['range'] = body_number + 1
# Sort by range: far to near for composition; also prepare near-to-far order for hit-test
sorted_body_models = sorted(body_models, key=lambda x: x['range'], reverse=True)
order_near_to_far = [
bp.get('name', f'SIM-BODY-{i + 1}').upper()
for i, bp in enumerate(sorted(body_models, key=lambda x: x['range']))
]
inventory: dict[str, dict[str, float]] = {}
body_model_dict: dict[str, dict[str, Any]] = {}
body_masks: list[NDArrayBoolType] = []
body_mask_map: dict[str, NDArrayBoolType] = {}
body_index_map: NDArrayIntType = np.zeros((size_v, size_u), dtype=np.int32)
ref_center_v = size_v / 2.0
ref_center_u = size_u / 2.0
for body_number, params in enumerate(sorted_body_models):
body_name = params.get('name', f'SIM-BODY-{body_number + 1}').upper()
center_v = float(params.get('center_v', size_v / 2.0)) + offset_v
center_u = float(params.get('center_u', size_u / 2.0)) + offset_u
axis1 = float(params.get('axis1', 0.0))
axis2 = float(params.get('axis2', 0.0))
axis3 = float(params.get('axis3', min(axis1, axis2)))
rotation_z = np.radians(params.get('rotation_z', 0.0))
rotation_tilt = np.radians(params.get('rotation_tilt', 0.0))
illumination_angle = np.radians(params.get('illumination_angle', 0.0))
phase_angle = np.radians(params.get('phase_angle', 0.0))
crater_fill = float(params.get('crater_fill', 0.0))
crater_min_radius = float(params.get('crater_min_radius', 0.05))
crater_max_radius = float(params.get('crater_max_radius', 0.25))
crater_power_law_exponent = float(params.get('crater_power_law_exponent', 3.0))
crater_relief_scale = float(params.get('crater_relief_scale', 0.6))
anti_aliasing = float(params.get('anti_aliasing', 1.0))
body_seed = seed if seed is not None else params.get('seed')
# Get cached body shape (at reference center)
body_shape = _render_body_shape_cached(
size_v,
size_u,
axis1,
axis2,
axis3,
rotation_z,
rotation_tilt,
illumination_angle,
phase_angle,
crater_fill,
crater_min_radius,
crater_max_radius,
crater_power_law_exponent,
crater_relief_scale,
anti_aliasing,
body_seed,
)
# Translate body from reference center to actual center
dv = center_v - ref_center_v
du = center_u - ref_center_u
# Create positioned body by translating the cached shape
# Use scipy for sub-pixel translation
positioned_body = ndimage.shift(body_shape, (dv, du), order=1, mode='constant', cval=0.0)
# Composition: overwrite where body contributes
mask = positioned_body > 0
img[mask] = positioned_body[mask]
body_masks.append(mask)
body_mask_map[body_name] = mask
# Index into near-to-far order is 1-based
near_index = order_near_to_far.index(body_name) + 1
body_index_map[mask] = near_index
max_dim = max(axis1, axis2, axis3) / 2.0 # Convert to half-width for dimension calculation
inventory_item = {
'v_min_unclipped': center_v - max_dim,
'v_max_unclipped': center_v + max_dim,
'u_min_unclipped': center_u - max_dim,
'u_max_unclipped': center_u + max_dim,
'v_pixel_size': 2 * max_dim,
'u_pixel_size': 2 * max_dim,
'range': params['range'],
}
inventory[body_name] = inventory_item
body_model_dict[body_name] = params
return {
'img': img,
'bodies': body_model_dict,
'inventory': inventory,
'body_masks': body_masks,
'body_mask_map': body_mask_map,
'order_near_to_far': order_near_to_far,
'body_index_map': body_index_map,
}
def _render_single_body(
img: NDArrayFloatType,
body_params: dict[str, Any],
offset_v: float,
offset_u: float,
*,
seed: int | None = None,
ref_center_v: float,
ref_center_u: float,
) -> tuple[NDArrayBoolType, dict[str, Any]]:
"""Render a single body into the image.
Parameters:
img: Image array to modify in-place.
body_params: Body parameters dictionary.
offset_v: V offset to apply.
offset_u: U offset to apply.
seed: Random seed for crater generation.
ref_center_v: Reference center V for body shape caching.
ref_center_u: Reference center U for body shape caching.
Returns:
Tuple of (body_mask, body_info_dict) where body_info_dict contains
name, inventory item, and model params.
"""
size_v, size_u = img.shape
body_name = body_params.get('name', 'SIM-BODY').upper()
center_v = float(body_params.get('center_v', size_v / 2.0)) + offset_v
center_u = float(body_params.get('center_u', size_u / 2.0)) + offset_u
axis1 = float(body_params.get('axis1', 0.0))
axis2 = float(body_params.get('axis2', 0.0))
axis3 = float(body_params.get('axis3', min(axis1, axis2)))
rotation_z = np.radians(body_params.get('rotation_z', 0.0))
rotation_tilt = np.radians(body_params.get('rotation_tilt', 0.0))
illumination_angle = np.radians(body_params.get('illumination_angle', 0.0))
phase_angle = np.radians(body_params.get('phase_angle', 0.0))
crater_fill = float(body_params.get('crater_fill', 0.0))
crater_min_radius = float(body_params.get('crater_min_radius', 0.05))
crater_max_radius = float(body_params.get('crater_max_radius', 0.25))
crater_power_law_exponent = float(body_params.get('crater_power_law_exponent', 3.0))
crater_relief_scale = float(body_params.get('crater_relief_scale', 0.6))
anti_aliasing = float(body_params.get('anti_aliasing', 1.0))
body_seed = seed if seed is not None else body_params.get('seed')
# Get cached body shape (at reference center)
body_shape = _render_body_shape_cached(
size_v,
size_u,
axis1,
axis2,
axis3,
rotation_z,
rotation_tilt,
illumination_angle,
phase_angle,
crater_fill,
crater_min_radius,
crater_max_radius,
crater_power_law_exponent,
crater_relief_scale,
anti_aliasing,
body_seed,
)
# Translate body from reference center to actual center
dv = center_v - ref_center_v
du = center_u - ref_center_u
# Create positioned body by translating the cached shape
positioned_body = ndimage.shift(body_shape, (dv, du), order=1, mode='constant', cval=0.0)
# Composition: overwrite where body contributes
mask = positioned_body > 0
img[mask] = positioned_body[mask]
max_dim = max(axis1, axis2, axis3) / 2.0
inventory_item = {
'v_min_unclipped': center_v - max_dim,
'v_max_unclipped': center_v + max_dim,
'u_min_unclipped': center_u - max_dim,
'u_max_unclipped': center_u + max_dim,
'v_pixel_size': 2 * max_dim,
'u_pixel_size': 2 * max_dim,
'range': body_params.get('range', 1.0),
}
return mask, {
'name': body_name,
'inventory': inventory_item,
'params': body_params,
}
[docs]
def render_bodies(
img: NDArrayFloatType,
bodies_params: list[dict[str, Any]],
offset_v: float,
offset_u: float,
*,
seed: int | None = None,
) -> dict[str, Any]:
"""Render bodies over img and return fields by name.
Returns: a dict with keys:
- img: NDArrayFloatType the rendered image
- bodies: dict[str, dict[str, Any]]
- inventory: dict[str, dict[str, float]]
- body_masks: list[NDArrayBoolType]
- order_near_to_far: list[str]
- body_index_map: NDArrayIntType (int32), 1-based index into order_near_to_far or 0 if none
"""
size_v, size_u = img.shape
# Separate parameters: body shapes (without center) and centers
bodies_params_no_center = []
centers = []
for params in bodies_params:
params_no_center = dict(params)
center_v = float(params_no_center.pop('center_v', size_v / 2.0))
center_u = float(params_no_center.pop('center_u', size_u / 2.0))
bodies_params_no_center.append(params_no_center)
centers.append((center_v, center_u))
bodies_params_no_center_json = json.dumps(bodies_params_no_center, sort_keys=True)
centers_json = json.dumps(centers, sort_keys=True)
cached_result = _render_bodies_positioned_cached(
size_v,
size_u,
bodies_params_no_center_json,
centers_json,
offset_v,
offset_u,
seed,
)
# Overwrite with bodies where they exist (preserve background noise/stars elsewhere)
body_img = cached_result['img']
body_mask = body_img > 0
img[body_mask] = body_img[body_mask]
# Return with copied arrays to avoid cache modification
return {
'img': img,
'bodies': cached_result['bodies'],
'inventory': cached_result['inventory'],
'body_masks': [m.copy() for m in cached_result['body_masks']],
'body_mask_map': {k: v.copy() for k, v in cached_result['body_mask_map'].items()},
'order_near_to_far': cached_result['order_near_to_far'],
'body_index_map': cached_result['body_index_map'].copy(),
}
@lru_cache(maxsize=1)
def _render_background_noise_cached(
size_v: int,
size_u: int,
noise_level: float,
seed: int,
) -> NDArrayFloatType:
"""Internal cached function to compute background noise."""
rng = np.random.RandomState(seed)
noise = rng.normal(0.0, noise_level, size=(size_v, size_u))
return noise
[docs]
def render_background_noise(img: NDArrayFloatType, noise_level: float, seed: int) -> None:
"""Add Gaussian background noise to the image.
Parameters:
img: Image array to modify in-place.
noise_level: Standard deviation of Gaussian noise (0-1).
seed: Random seed for reproducibility.
"""
if noise_level <= 0:
return
size_v, size_u = img.shape
noise = _render_background_noise_cached(size_v, size_u, noise_level, seed)
img[:] = np.clip(img + noise, 0.0, 1.0)
@lru_cache(maxsize=1)
def _render_background_stars_cached(
size_v: int,
size_u: int,
n_stars: int,
seed: int,
psf_sigma: float,
distribution_exponent: float,
) -> NDArrayFloatType:
"""Internal cached function to compute background star additions."""
rng = np.random.RandomState(seed)
star_additions = np.zeros((size_v, size_u), dtype=np.float64)
# Power law for intensity: weight toward dimmer stars
# intensity = uniform^power where power > 1 makes dimmer stars more common
uniform_samples = rng.uniform(0.0, 1.0, size=n_stars)
intensities = uniform_samples**distribution_exponent
# PSF size: at least 11x11, but scale with sigma
# Use at least 3*sigma pixels on each side, minimum 6 for 11x11
psf_size_half = max(6, int(np.ceil(3.0 * psf_sigma)))
psf = GaussianPSF(sigma=psf_sigma)
for i in range(n_stars):
# Random position
v = rng.uniform(0.0, float(size_v))
u = rng.uniform(0.0, float(size_u))
v_int = int(v)
u_int = int(u)
v_frac = v - v_int
u_frac = u - u_int
# Skip if too close to edge
if (
u_int < psf_size_half
or u_int >= size_u - psf_size_half
or v_int < psf_size_half
or v_int >= size_v - psf_size_half
):
continue
# Generate PSF (normalized so peak is 1.0)
star_psf = psf.eval_rect(
(psf_size_half * 2 + 1, psf_size_half * 2 + 1),
offset=(v_frac, u_frac),
scale=1.0, # Use scale=1.0 to get normalized PSF
movement=(0.0, 0.0),
movement_granularity=1.0,
)
# Normalize PSF to have peak value of 1.0, then scale by intensity
# This ensures stars are bright (peak brightness = intensity, not distributed)
psf_max = np.max(star_psf)
if psf_max > 0:
star_psf = star_psf / psf_max * intensities[i]
else:
star_psf = star_psf * intensities[i]
# Add to star additions accumulator
star_additions[
v_int - psf_size_half : v_int + psf_size_half + 1,
u_int - psf_size_half : u_int + psf_size_half + 1,
] += star_psf
return star_additions
[docs]
def render_background_stars(
img: NDArrayFloatType,
n_stars: int,
seed: int,
psf_sigma: float = 0.9,
distribution_exponent: float = 2.5,
) -> None:
"""Add random background stars to the image.
Parameters:
img: Image array to modify in-place (stars are added, not overwritten).
n_stars: Number of stars to add (0-1000).
seed: Random seed for reproducibility.
psf_sigma: PSF sigma value for star rendering (default 0.9).
distribution_exponent: Power law exponent for intensity distribution (default 2.5).
Higher values make dimmer stars more common.
"""
if n_stars <= 0:
return
size_v, size_u = img.shape
star_additions = _render_background_stars_cached(
size_v, size_u, n_stars, seed, psf_sigma, distribution_exponent
)
img[:] = np.clip(img + star_additions, 0.0, 1.0)
@lru_cache(maxsize=1)
def _render_combined_model_cached(
sim_params_json: str,
*,
ignore_offset: bool,
) -> tuple[NDArrayFloatType, dict[str, Any]]:
"""Internal cached function to compute combined model rendering."""
sim_params = json.loads(sim_params_json)
size_v = int(sim_params['size_v'])
size_u = int(sim_params['size_u'])
if not ignore_offset:
offset_v = float(sim_params.get('offset_v', 0.0))
offset_u = float(sim_params.get('offset_u', 0.0))
else:
offset_v = 0.0
offset_u = 0.0
img = cast(NDArrayFloatType, np.zeros((size_v, size_u), dtype=np.float64))
# Get random seed for background effects
random_seed = int(sim_params.get('random_seed', 42))
# Apply background noise first
background_noise_intensity = float(sim_params.get('background_noise_intensity', 0.0))
render_background_noise(img, background_noise_intensity, random_seed)
# Then background stars
background_stars_num = int(sim_params.get('background_stars_num', 0))
background_stars_psf_sigma = float(sim_params.get('background_stars_psf_sigma', 0.9))
background_stars_distribution_exponent = float(
sim_params.get('background_stars_distribution_exponent', 2.5)
)
render_background_stars(
img,
background_stars_num,
random_seed,
psf_sigma=background_stars_psf_sigma,
distribution_exponent=background_stars_distribution_exponent,
)
stars_params = sim_params.get('stars', []) or []
bodies_params = sim_params.get('bodies', []) or []
rings_params = sim_params.get('rings', []) or []
img, sim_star_list, star_info = render_stars(img, stars_params, offset_v, offset_u)
# Process rings: assign default ranges
for ring_number, ring_params in enumerate(rings_params):
if 'range' in ring_params:
ring_params['range'] = float(ring_params['range'])
else:
# Default range: start after bodies (assuming bodies use 1, 2, 3, ...)
# Use a large starting value to ensure rings are behind bodies by default
ring_params['range'] = float(ring_number + 1000.0)
# Process bodies: assign default ranges
bodies_with_ranges = []
for body_number, body_params in enumerate(bodies_params):
body_params_copy = dict(body_params)
if 'range' in body_params_copy:
body_params_copy['range'] = float(body_params_copy['range'])
else:
body_params_copy['range'] = float(body_number + 1)
bodies_with_ranges.append(body_params_copy)
# Combine rings and bodies, sort by range (far to near)
render_items: list[tuple[float, str, Any, int]] = []
for idx, ring_params in enumerate(rings_params):
render_items.append((ring_params['range'], 'ring', ring_params, idx))
for idx, body_params in enumerate(bodies_with_ranges):
render_items.append((body_params['range'], 'body', body_params, idx))
# Sort all items by range (far to near)
render_items.sort(key=lambda x: x[0], reverse=True)
# Render in range order (far to near)
time = float(sim_params.get('time', 0.0))
epoch = float(sim_params.get('ring_epoch', 0.0))
# Get shade_solid_rings setting from sim_params
shade_solid = bool(sim_params.get('shade_solid_rings', False))
ring_masks: list[NDArrayBoolType] = []
# Track ring masks in original order for click detection
ring_mask_map: dict[int, NDArrayBoolType] = {}
# Track body data for final metadata
body_models_dict: dict[str, dict[str, Any]] = {}
# Store body masks by original index, not render order
body_mask_map_by_idx: dict[int, NDArrayBoolType] = {}
body_mask_map_dict: dict[str, NDArrayBoolType] = {}
inventory_dict: dict[str, dict[str, float]] = {}
body_index_map: NDArrayIntType = np.zeros((size_v, size_u), dtype=np.int32)
ref_center_v = size_v / 2.0
ref_center_u = size_u / 2.0
# Build order_near_to_far for bodies (needed for body_index_map)
sorted_bodies_by_range = sorted(bodies_with_ranges, key=lambda x: x['range'])
order_near_to_far = [
bp.get('name', f'SIM-BODY-{i + 1}').upper() for i, bp in enumerate(sorted_bodies_by_range)
]
for _range_val, item_type, item_params, orig_idx in render_items:
if item_type == 'ring':
feature_type = item_params.get('feature_type', 'RINGLET')
# Render ring into temporary image to extract coverage
# For proper range-based composition, we need the ring contribution only
if feature_type == 'RINGLET':
# For RINGLET: render into empty image to get ring_coverage
ring_img = np.zeros((size_v, size_u), dtype=np.float64)
render_ring(
ring_img,
item_params,
offset_v,
offset_u,
time=time,
epoch=epoch,
shade_solid=shade_solid,
)
# ring_img now contains just the ring_coverage (since 0 + coverage = coverage)
ring_mask = ring_img > 0.0
ring_mask_map[orig_idx] = ring_mask
# Add ring to main image (proper range-based: lower range overwrites)
img[ring_mask] = ring_img[ring_mask]
else: # GAP
# For GAP: render into image with known background to extract coverage
# Use 1.0 as background so we can see what was subtracted
temp_bg = np.ones((size_v, size_u), dtype=np.float64)
render_ring(
temp_bg,
item_params,
offset_v,
offset_u,
time=time,
epoch=epoch,
shade_solid=shade_solid,
)
# gap_coverage is what was subtracted: 1.0 - result
ring_mask = temp_bg < 1.0
ring_mask_map[orig_idx] = ring_mask
# Subtract gap from main image (proper range-based: lower range overwrites)
img[ring_mask] = temp_bg[ring_mask]
elif item_type == 'body':
# Render single body
body_mask, body_info = _render_single_body(
img,
item_params,
offset_v,
offset_u,
seed=random_seed,
ref_center_v=ref_center_v,
ref_center_u=ref_center_u,
)
# Store mask by original index for proper ordering
body_mask_map_by_idx[orig_idx] = body_mask
body_mask_map_dict[body_info['name']] = body_mask
body_models_dict[body_info['name']] = body_info['params']
inventory_dict[body_info['name']] = body_info['inventory']
# Index into near-to-far order is 1-based
near_index = order_near_to_far.index(body_info['name']) + 1
body_index_map[body_mask] = near_index
# Build body_masks_list in original order (matching bodies_params)
body_masks_list: list[NDArrayBoolType] = []
for idx in range(len(bodies_with_ranges)):
if idx in body_mask_map_by_idx:
body_masks_list.append(body_mask_map_by_idx[idx])
else:
# Should not happen, but create empty mask if missing
body_masks_list.append(np.zeros((size_v, size_u), dtype=np.bool_))
# Build ring_masks in original order for click detection
for idx in range(len(rings_params)):
if idx in ring_mask_map:
ring_masks.append(ring_mask_map[idx])
else:
# Should not happen, but create empty mask if missing
ring_masks.append(np.zeros((size_v, size_u), dtype=np.bool_))
# Create bodies_result dict in the same format as render_bodies
# Note: img is already the correct variable, no need to reassign
body_models = body_models_dict
inventory = inventory_dict
body_masks = body_masks_list
# order_near_to_far and body_index_map are already defined above
meta: dict[str, Any] = {
'stars': sim_star_list,
'bodies': body_models,
'rings': rings_params,
'inventory': inventory,
'star_info': star_info,
'body_masks': body_masks,
'ring_masks': ring_masks,
'order_near_to_far': order_near_to_far,
'body_index_map': body_index_map,
}
return img, meta
[docs]
def render_combined_model(
sim_params: dict[str, Any], *, ignore_offset: bool = False
) -> tuple[NDArrayFloatType, dict[str, Any]]:
"""Render stars then bodies from a full sim_params dict. Returns (img, meta).
ignore_offset = True should be used when rendering the image in the GUI, but not
when creating the simulated image to navigate.
Parameters:
sim_params: The parameters describing the simulated model.
ignore_offset: Whether to ignore the offset.
Returns:
A tuple containing the image and metadata.
"""
# Create cache key from parameters (exclude offset if ignore_offset is True)
params_for_hash = dict(sim_params)
if ignore_offset:
params_for_hash = {
k: v for k, v in params_for_hash.items() if k not in ('offset_v', 'offset_u')
}
sim_params_json = json.dumps(params_for_hash, sort_keys=True)
cached_img, cached_meta = _render_combined_model_cached(
sim_params_json, ignore_offset=ignore_offset
)
# Return copies to avoid cache modification
# Use deepcopy for meta to fully isolate nested mutable structures
return cached_img.copy(), copy.deepcopy(cached_meta)