"""Translucent-screen compositing and depth-ambiguity checks for the radiance stage.
The radiance stage (:mod:`spindoctor.sim.forward.scene_radiance`) paints the
opaque body stack far to near and then composites the scene's translucent
screens -- the optical-depth ring system and the atmospheric bodies' above-limb
halos -- over the painted image. This module owns that compositing machinery:
:func:`translucent_screen_ops` orders the screens far to near (the ring's
per-pixel depth interleaving with the halos' scalar body ranges) and returns
them as :class:`ScreenOp` applications, and the ``check_*`` functions enforce
the depth contract behind every ordering decision -- overlapping objects must
ALL carry an explicit scene ``range_km``, or their stacking would be a silent
guess and the scene fails loudly.
Everything here operates on rendered geometry the radiance stage hands in
(painted masks, halo screens, ring maps); no scene mapping is read directly.
"""
from dataclasses import dataclass
import numpy as np
from spindoctor.sim.forward.atmosphere import HaloScreen
from spindoctor.sim.forward.ring_system import RingSystemMaps
from spindoctor.sim.scene_schema import SimSceneValidationError
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType
__all__ = [
'ScreenOp',
'check_depth_ambiguity',
'check_halo_ambiguity',
'check_ring_system_ambiguity',
'translucent_screen_ops',
]
[docs]
@dataclass(frozen=True)
class ScreenOp:
"""One translucent-screen application over the composed image.
Applied to the ``(box_v, box_u)`` view of the frame as ``view[mask] =
intensity[mask] + transmission[mask] * view[mask]`` (the screen is
identity outside its box); ``is_ring`` routes the emission to the rings
class when the differential-smear layers replay the ops per class.
Parameters:
box_v: Frame rows the op is restricted to.
box_u: Frame columns the op is restricted to.
mask: Box-sized mask of the pixels the screen composites over.
intensity: Box-sized emission map.
transmission: Box-sized per-pixel background transmission.
is_ring: Whether the emission belongs to the rings class (else the
bodies class -- a body halo).
"""
box_v: slice
box_u: slice
mask: NDArrayBoolType
intensity: NDArrayFloatType
transmission: NDArrayFloatType
is_ring: bool
[docs]
def translucent_screen_ops(
halo_screens: list[tuple[float, HaloScreen]],
*,
ring_maps: RingSystemMaps | None,
ring_apply: NDArrayBoolType | None,
body_depth_map: NDArrayFloatType | None,
) -> list[ScreenOp]:
"""Order the scene's translucent screens far to near for compositing.
Halo screens arrive in far-to-near body order. The ring system's
per-pixel depth interleaves with the halos' scalar body ranges: the ring
pixels at or beyond a halo's body range apply before that halo (the ring
shows through the glow, attenuated), and the pixels nearer than every
halo apply last (the ring screens the glow). A ring system without a
``range_km`` has no depth relation, so it applies last in full. Each
halo composites only where no nearer opaque body covers it.
Parameters:
halo_screens: ``(body range_km, halo screen)`` per atmospheric body,
far to near.
ring_maps: The rendered ring-system maps, or None.
ring_apply: Ring pixels that survive the body depth test, or None.
body_depth_map: Per-pixel depth of the nearest painted body; present
whenever any screen exists.
Returns:
The screen applications, in application (far-to-near) order.
"""
full = slice(None)
ops: list[ScreenOp] = []
remaining_ring: NDArrayBoolType | None = None
if ring_maps is not None and ring_apply is not None:
remaining_ring = ring_apply.copy() if halo_screens else ring_apply
for halo_range, screen in halo_screens:
if remaining_ring is not None and ring_maps is not None and ring_maps.depth_km is not None:
behind = remaining_ring & (ring_maps.depth_km >= halo_range)
if behind.any():
ops.append(
ScreenOp(
box_v=full,
box_u=full,
mask=behind,
intensity=ring_maps.intensity,
transmission=ring_maps.transmission,
is_ring=True,
)
)
remaining_ring = remaining_ring & ~behind
assert body_depth_map is not None
# The screen is identity outside its bounding box, so the op (and
# every consumer's work) is restricted to the box exactly.
box_v, box_u = screen.box_v, screen.box_u
emission = screen.emission[box_v, box_u]
transmission = screen.transmission[box_v, box_u]
visible = screen.mask & (body_depth_map[box_v, box_u] > halo_range)
if visible.any():
ops.append(
ScreenOp(
box_v=box_v,
box_u=box_u,
mask=visible,
intensity=emission,
transmission=transmission,
is_ring=False,
)
)
if remaining_ring is not None and ring_maps is not None and remaining_ring.any():
ops.append(
ScreenOp(
box_v=full,
box_u=full,
mask=remaining_ring,
intensity=ring_maps.intensity,
transmission=ring_maps.transmission,
is_ring=True,
)
)
return ops
[docs]
def check_depth_ambiguity(
painted_items: list[tuple[str, NDArrayBoolType, bool]],
item_label: str,
item_mask: NDArrayBoolType,
explicit_depth: bool,
) -> None:
"""Fail when overlapping bodies are stacked without explicit depths.
``range_km`` is the compositing depth. Two bodies whose painted pixels
overlap must both carry an explicit scene ``range_km``, or their stacking
order would be a silent guess; a scene that leaves it off an overlapping
body is malformed and fails loudly here. Non-overlapping bodies need
no ranges: their paint order is unobservable.
Parameters:
painted_items: ``(label, painted mask, explicit range_km?)`` for
every body already painted, in render order.
item_label: Name of the body just painted.
item_mask: Pixels the body just painted.
explicit_depth: Whether the body carries an explicit ``range_km``.
Raises:
spindoctor.sim.scene_schema.SimSceneValidationError: If the new
object overlaps a painted one and either of the pair lacks an
explicit ``range_km``.
"""
if not item_mask.any():
return
for other_label, other_mask, other_explicit in painted_items:
if explicit_depth and other_explicit:
continue
if np.any(item_mask & other_mask):
raise SimSceneValidationError(
f'objects {other_label!r} and {item_label!r} overlap but do not both '
f'carry an explicit range_km; set range_km on both to order them'
)
def _halo_screens_overlap(screen_a: HaloScreen, screen_b: HaloScreen) -> bool:
"""Whether two halo screens carry haze on any common pixel.
Each screen's mask is box-sized, so the test intersects the two bounding
boxes and compares the corresponding mask windows.
Parameters:
screen_a: One halo screen.
screen_b: The other halo screen.
Returns:
True when the screens' masked pixels intersect.
"""
v_lo = max(screen_a.box_v.start, screen_b.box_v.start)
v_hi = min(screen_a.box_v.stop, screen_b.box_v.stop)
u_lo = max(screen_a.box_u.start, screen_b.box_u.start)
u_hi = min(screen_a.box_u.stop, screen_b.box_u.stop)
if v_lo >= v_hi or u_lo >= u_hi:
return False
window_a = screen_a.mask[
v_lo - screen_a.box_v.start : v_hi - screen_a.box_v.start,
u_lo - screen_a.box_u.start : u_hi - screen_a.box_u.start,
]
window_b = screen_b.mask[
v_lo - screen_b.box_v.start : v_hi - screen_b.box_v.start,
u_lo - screen_b.box_u.start : u_hi - screen_b.box_u.start,
]
return bool(np.any(window_a & window_b))
[docs]
def check_halo_ambiguity(
painted_items: list[tuple[str, NDArrayBoolType, bool]],
halo_check_items: list[tuple[str, bool, HaloScreen]],
) -> None:
"""Fail when a halo overlaps another body or halo without explicit depths.
A halo composites against everything it covers by its body's
``range_km``, so an overlap ordered only by the positional default
ranges would be a silent guess exactly like an opaque overlap: a halo
that reaches another body's painted silhouette, or another body's halo,
requires an explicit scene ``range_km`` on both bodies. A halo over
only the empty sky needs no range: its order is unobservable.
Parameters:
painted_items: ``(label, painted mask, explicit range_km?)`` for
every painted body.
halo_check_items: ``(body label, explicit range_km?, halo screen)``
for every atmospheric body, in render order.
Raises:
spindoctor.sim.scene_schema.SimSceneValidationError: If a halo
overlaps another body's paint or halo and either body lacks an
explicit ``range_km``.
"""
for halo_label, halo_explicit, screen in halo_check_items:
if not screen.mask.any():
continue
box = (screen.box_v, screen.box_u)
for other_label, other_mask, other_explicit in painted_items:
if other_label == halo_label:
continue
if halo_explicit and other_explicit:
continue
if np.any(other_mask[box] & screen.mask):
raise SimSceneValidationError(
f'the halo of body {halo_label!r} overlaps body {other_label!r} '
f'but they do not both carry an explicit range_km; set range_km '
f'on both to order them'
)
for index, (label_a, explicit_a, screen_a) in enumerate(halo_check_items):
for label_b, explicit_b, screen_b in halo_check_items[index + 1 :]:
if explicit_a and explicit_b:
continue
if _halo_screens_overlap(screen_a, screen_b):
raise SimSceneValidationError(
f'the halos of bodies {label_a!r} and {label_b!r} overlap but '
f'they do not both carry an explicit range_km; set range_km on '
f'both to order them'
)
[docs]
def check_ring_system_ambiguity(
painted_items: list[tuple[str, NDArrayBoolType, bool]],
halo_check_items: list[tuple[str, bool, HaloScreen]],
ring_mask: NDArrayBoolType,
*,
ring_explicit: bool,
) -> None:
"""Fail when the ring system overlaps bodies or halos without depths.
Per-pixel depth ordering against a body needs an explicit ``range_km`` on
both the ring system and the body. A body's translucent halo takes the
same rule: the ring interleaves with a halo by the two ranges, so a ring
over a halo ordered only by positional defaults (or a depth-less ring
that would silently screen the halo) is the same ambiguity.
Parameters:
painted_items: ``(label, painted mask, explicit range_km?)`` for
every painted body.
halo_check_items: ``(body label, explicit range_km?, halo screen)``
for every atmospheric body, in render order.
ring_mask: Pixels where the ring system carries optical depth.
ring_explicit: Whether the ring system carries an explicit
``range_km``.
Raises:
spindoctor.sim.scene_schema.SimSceneValidationError: On any overlap
without a defined depth relation.
"""
if not ring_mask.any():
return
for label, mask, explicit in painted_items:
if not np.any(mask & ring_mask):
continue
if not (ring_explicit and explicit):
raise SimSceneValidationError(
f'ring_system and body {label!r} overlap but do not both carry an '
f'explicit range_km; set range_km on both to order them'
)
for halo_label, halo_explicit, screen in halo_check_items:
if ring_explicit and halo_explicit:
continue
if not screen.mask.any():
continue
if np.any(ring_mask[screen.box_v, screen.box_u] & screen.mask):
raise SimSceneValidationError(
f'ring_system and the halo of body {halo_label!r} overlap but do '
f'not both carry an explicit range_km; set range_km on both to '
f'order them'
)