Source code for spindoctor.sim.forward.incidence

"""Artifact-incidence measurement: planted truth vs image-measured counts.

The realism match compares how many defects a scene *planted* against how many a
detector could *measure* on the rendered frame.  This module carries both sides:

* :func:`planted_incidence` reads the realized geometry the telemetry stage
  wrote into ``frame.truth['artifacts']`` and returns a realized event count per
  mode (lost lines, truncated lines, lost blocks, dropped or spiked pixels, dead
  columns, or a commanded on/off).  This is ground truth: it is exact by
  construction, since each applier records exactly what it touched.

* The ``measured_*`` estimators recover the same counts from the DN image alone,
  the way a detector run against a real archive frame would, with no access to
  the truth record.  They key on the missing-data marker (the DN a lost pixel
  carries: 0 on the raw-DN path, NaN on the calibrated path), so they are exact
  for the marker-based structural modes and only those.

**Scope of the image estimators.**  A lost line, a truncated line, a lost block,
and a dead pixel each overwrite pixels with the marker value, so a marker-run
analysis recovers them exactly on a frame whose scene content is not itself at
the marker level (a body or star frame with a non-zero bias floor; the dark-sky
floor of an unbiased frame sits at the marker and is indistinguishable from a
dropout).  Modes that plant *wrong* values rather than the marker -- garble
(random DN), pixel spikes (bit-flips), and every detector-electronics mode --
are not recoverable pixel-by-pixel from a single frame: distinguishing them from
scene structure needs multi-frame or noise statistics, which is out of scope
here.  For those modes, use :func:`planted_incidence` (the truth side) only.
"""

from __future__ import annotations

import math
from collections.abc import Mapping
from typing import Any

import numpy as np

from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType

__all__ = [
    'marker_mask',
    'measured_missing_blocks',
    'measured_missing_lines',
    'measured_partial_lines',
    'measured_pixel_dropouts',
    'planted_incidence',
]

# The per-mode truth-record fields whose list length is the realized event
# count, tried in order.  A record that carries none of them but does carry an
# ``active`` flag is a commanded on/off mode (count 1 when active, else 0).
_COUNT_FIELDS: tuple[str, ...] = ('lines', 'cuts', 'blocks', 'pixels', 'columns')


def _realized_count(record: Mapping[str, Any]) -> int:
    """The realized event count for one mode's truth record.

    Parameters:
        record: A ``frame.truth['artifacts'][mode]`` map.

    Returns:
        The number of geometric events the mode planted: the length of its
        geometry list (lost lines, cuts, blocks, pixels, or columns), or 1/0 for
        a commanded on/off mode.
    """
    for field in _COUNT_FIELDS:
        value = record.get(field)
        if isinstance(value, list):
            return len(value)
    if 'active' in record:
        return 1 if bool(record['active']) else 0
    return 0


[docs] def planted_incidence(truth: Mapping[str, Any]) -> dict[str, int]: """Realized per-mode event counts from a rendered frame's truth record. Parameters: truth: A rendered frame's ``truth`` mapping (``frame.truth``); its ``artifacts`` sub-map holds one record per applied mode. A frame with no artifacts returns an empty mapping. Returns: A mapping from mode name to its realized event count, for every mode the telemetry stage recorded. A mode that rendered as a no-op (incidence 0) leaves no record and does not appear. """ artifacts = truth.get('artifacts') if not isinstance(artifacts, Mapping): return {} return { mode: _realized_count(record) for mode, record in artifacts.items() if isinstance(record, Mapping) }
[docs] def marker_mask(image: NDArrayFloatType, marker_value: float) -> NDArrayBoolType: """Boolean mask of pixels carrying the missing-data marker. Parameters: image: The DN image. marker_value: The marker DN (0 on the raw-DN path, NaN on the calibrated path); NaN is matched with :func:`numpy.isnan`. Returns: A boolean array, True where the pixel equals the marker. """ mask: NDArrayBoolType = np.isnan(image) if math.isnan(marker_value) else (image == marker_value) return mask
[docs] def measured_missing_lines(image: NDArrayFloatType, *, marker_value: float = 0.0) -> int: """Count whole image lines lost to the marker (every pixel is the marker). A missing line zeros the full row, so a fully-marker row is a lost line. A scene row that merely crosses the dark sky is not fully marker as long as the frame carries a non-zero floor (a bias pedestal or exposed scene), so the count is exact for the structural loss mode on such a frame. Parameters: image: The DN image. marker_value: The missing-data marker DN. Returns: The number of fully-marker rows. """ mask = marker_mask(image, marker_value) return int(mask.all(axis=1).sum())
[docs] def measured_partial_lines( image: NDArrayFloatType, *, marker_value: float = 0.0, min_run: int = 2 ) -> int: """Count truncated lines: rows with a long marker run but not a whole one. A partial line loses a contiguous segment (from a column to the row end, or a middle segment), leaving a survivor, so an affected row carries a contiguous marker run yet is not fully marker. The ``min_run`` floor separates a truncated line from an incidental single dropped pixel or a lone dead column crossing the row; a fully-marker row is a whole missing line and is excluded. Parameters: image: The DN image. marker_value: The missing-data marker DN. min_run: The shortest contiguous marker run counted as a truncation. Returns: The number of rows whose longest contiguous marker run is at least ``min_run`` and that are not entirely marker. """ mask = marker_mask(image, marker_value) count = 0 for row in mask: if row.all() or not row.any(): continue if _longest_run(row) >= min_run: count += 1 return count
[docs] def measured_missing_blocks( image: NDArrayFloatType, *, block_lines: int, marker_value: float = 0.0 ) -> int: """Count compression blocks lost to the marker on the block-row grid. Blocks align to the ``block_lines`` row grid; a lost block zeros every row of its block, so a grid-aligned band of ``block_lines`` fully-marker rows is one lost block. Parameters: image: The DN image. block_lines: The compression block height in rows. marker_value: The missing-data marker DN. Returns: The number of grid-aligned blocks that are entirely marker. """ if block_lines <= 0: raise ValueError(f'block_lines must be positive; got {block_lines}') mask = marker_mask(image, marker_value) full_rows = mask.all(axis=1) size_v = full_rows.shape[0] count = 0 for start in range(0, size_v, block_lines): if bool(full_rows[start : start + block_lines].all()): count += 1 return count
[docs] def measured_pixel_dropouts(image: NDArrayFloatType, *, marker_value: float = 0.0) -> int: """Count isolated marker pixels (dead pixels), excluding lines and columns. A dead pixel is a singleton at the marker. Marker pixels that belong to a fully-marker row (a missing line) or a fully-marker column (a dead column) are excluded, so the count is the isolated-dropout population on a frame whose scene floor is above the marker. Parameters: image: The DN image. marker_value: The missing-data marker DN. Returns: The number of marker pixels not lying in a fully-marker row or column. """ mask = marker_mask(image, marker_value) if not mask.any(): return 0 full_rows = mask.all(axis=1) full_cols = mask.all(axis=0) isolated = mask & ~full_rows[:, None] & ~full_cols[None, :] return int(isolated.sum())
def _longest_run(row: np.ndarray) -> int: """The length of the longest contiguous True run in a boolean row.""" best = 0 current = 0 for flag in row.tolist(): if flag: current += 1 best = max(best, current) else: current = 0 return best