"""The navigator's predicted-body renderer for simulated scenes.
Renders the smooth Lambert ellipsoid ``NavModelBodySimulated`` predicts
from a scene's idealized body geometry. This is deliberately the
navigator's *best model*, not the image: surface texture (craters, relief)
is truth-side information rendered only by the image-side twin
(``spindoctor.sim.forward.body``), and the difference between the two is
exactly the model error a scene plants. Shading conventions are shared
with the image side through :mod:`spindoctor.sim.ellipsoid_geometry`, so
that planted difference is the only difference.
"""
from typing import cast
import numpy as np
from spindoctor.sim.ellipsoid_geometry import (
ellipsoid_image_normals,
lambert_from_normals,
project_ellipsoid,
)
from spindoctor.support.types import NDArrayFloatType
__all__ = ['create_simulated_body']
[docs]
def create_simulated_body(
size: tuple[int, int],
center: tuple[float, float],
axis1: float,
*,
axis2: float,
axis3: float,
rotation_z: float = 0.0,
rotation_tilt: float = 0.0,
illumination_angle: float = 0.0,
phase_angle: float = 0.0,
anti_aliasing: float = 0.0,
) -> NDArrayFloatType:
"""Render the predicted body: a smooth ellipsoid with Lambertian shading.
The body is modeled as a 3D ellipsoid projected onto 2D and illuminated
using Lambertian shading (cos(incidence)) based on the illumination
direction and phase angle.
Parameters:
size: Tuple of (size_v, size_u) giving the image dimensions in pixels.
center: Tuple of (v, u) giving the center position in floating-point pixels.
(0.0, 0.0) is the top-left corner of pixel (0,0), (0.5, 0.5) is the
center of pixel (0,0).
axis1: The full width of axis 1 (a) of the ellipsoid in pixels.
axis2: The full width of axis 2 (b) of the ellipsoid in pixels.
axis3: The full width of axis 3 (c) of the ellipsoid in pixels (depth).
rotation_z: Rotation angle around the viewing axis (z-axis) in radians (0 to 2pi).
rotation_tilt: Tilt angle of the ellipsoid in radians (0 to pi/2).
Controls how much the ellipsoid is tilted toward/away from the viewer.
illumination_angle: Direction of illumination in the image plane in radians (0 to 2pi).
0 radians is at the top of the image, pi/2 is to the right.
phase_angle: Phase angle in radians (0 to pi).
0 = head-on illumination (fully illuminated),
pi/2 = side illumination (half illuminated),
pi = back illumination (no visible illumination).
anti_aliasing: Float between 0 and 1 controlling anti-aliasing amount at the limb.
0 = no anti-aliasing, 1 = maximum anti-aliasing. Only affects the edge.
Returns:
A 2D numpy array of shape (size_v, size_u) with float values from 0.0 to 1.0,
where 0.0 is black and 1.0 is full white.
"""
size_v, size_u = size
# The projection grids (working-resolution coordinate frames, hemisphere
# depth, coverage mask) come from the shared geometry module, so this
# predicted-body renderer and the image-side renderer project through one
# implementation.
proj = project_ellipsoid(
size,
center,
axis1,
axis2=axis2,
axis3=axis3,
rotation_z=rotation_z,
rotation_tilt=rotation_tilt,
anti_aliasing=anti_aliasing,
)
aa_scale = proj.aa_scale
ellipse_mask = proj.ellipse_mask
normal_v, normal_u, normal_z = ellipsoid_image_normals(
ellipse_mask,
proj.v_rot,
proj.u_rot,
z_coords=proj.z_coords,
work_semi_major=proj.work_semi_major,
work_semi_minor=proj.work_semi_minor,
work_semi_c=proj.work_semi_c,
cos_rz=proj.cos_rz,
sin_rz=proj.sin_rz,
)
intensity = (
lambert_from_normals(
normal_v,
normal_u,
normal_z,
illumination_angle=illumination_angle,
phase_angle=phase_angle,
)
* ellipse_mask
)
# Downsample if anti-aliasing was used
if aa_scale > 1:
# Simple box filter downsampling
intensity = intensity.reshape(size_v, aa_scale, size_u, aa_scale).mean(axis=(1, 3))
# Ensure values are in [0, 1] range
return cast(NDArrayFloatType, np.clip(intensity, 0.0, 1.0))