Pipeline
========
This section covers the CLI-to-output-file path: a flowchart of the
major functions and a walk-through of each.
Flowchart
---------
The diagram below shows the path from a CLI invocation to a written
output file. Cross-references below link each box to its API entry;
the diagram itself uses bare names so it stays legible. The diagram is
rendered as inline SVG by Mermaid (client-side); use the browser's
zoom controls to read the labels at any size.
.. mermaid::
:align: center
flowchart TD
A[picmaker CLI
argv] --> B[cli.main]
B --> C[_build_parser
argparse]
B --> D[_separate_files_and_dirs]
B --> CO[_collect_option_dicts]
CO --> E[_normalize_and_validate
per --versions line]
E --> F[PicmakerOptions.validate]
B --> PD[_process_directory
per dirpath, recursive or not]
F --> G[process_images
per directory]
PD --> G
G -->|movie=True| H[images_to_pics
pass 1: collect limits]
H --> I[images_to_pics
pass 2: shared stretch]
G -->|movie=False| J[images_to_pics
per file]
I --> K[_process_one_image]
J --> K
K --> L[get_outfile]
L -->|skip if replace='none'| M[Done]
L --> PR[_pds3_resolve_pointer
only for .LBL inputs]
PR --> N[read_image_array]
L --> N
N --> O[read_one_image_array
format cascade]
O --> P[pickle / numpy / VICAR / FITS / PIL / PDS3]
P --> Q[ReadResult
array3d, default_is_up, filter_info]
Q --> R{hst=True?}
R -->|yes| S[_hst_mosaic_rgb
WFPC2 quad or ACS panel mosaic]
R -->|no| T[slice_array]
T --> U[fill_zebra_stripes
optional]
U --> V[get_limits]
V --> W[apply_colormap]
W --> X[rotate_array_rgb]
X --> Y[apply_gamma]
Y --> Z[get_size + array_to_pil]
S --> Z
Z --> AA[filter_image]
AA --> BB[resize_image]
BB --> CC{sections > 1?}
CC -->|yes| DD[wrap_image]
CC -->|no| EE[skip wrap]
DD --> FF{pad?}
EE --> FF
FF -->|yes| GG[pad_image]
FF -->|no| HH[skip pad]
GG --> II[write_pil]
HH --> II
II --> M
Two short observations on the diagram:
* The ``movie=True`` branch runs :func:`~picmaker.pipeline.images_to_pics`
twice. The first pass computes the per-frame limits, the second
pass uses the median of those limits so every frame shares one
stretch.
* The HST mosaic branch (``hst=True``) handles WFPC2 quad-panel and
ACS/WFC two-panel composites; it is the only branch that bypasses
the single-band :func:`~picmaker.geometry.slice_array` →
:func:`~picmaker.enhance.apply_colormap` flow.
Major functions
---------------
This subsection walks through the major library functions in pipeline
order. Cross-references resolve to the :doc:`/module` reference; each
public symbol is a clickable link to its full signature, docstring,
and source code (via :mod:`sphinx.ext.viewcode`).
CLI entry point
~~~~~~~~~~~~~~~
:func:`picmaker.cli.main` is the function bound to the ``picmaker``
console script. It builds the argparse parser, splits ``args.files``
into files and directories with
:func:`!picmaker.cli._separate_files_and_dirs`, and delegates the two
remaining phases to two private helpers:
:func:`!picmaker.cli._collect_option_dicts` (the ``--versions FILE``
re-parse loop, returning one normalized option_dict per line) and
:func:`!picmaker.cli._process_directory` (the per-directory walk in
recursive or non-recursive mode). Each helper is unit-tested directly
in :file:`tests/test_cli_helpers.py`.
The library equivalent of "run the CLI from Python" is to import
:func:`picmaker.pipeline.images_to_pics` directly; the kwarg names
match the CLI flags one-to-one. The CLI does no I/O of its own —
every file operation flows through :mod:`picmaker.io`.
Option validation
~~~~~~~~~~~~~~~~~
:class:`picmaker.options.PicmakerOptions` is a frozen-by-convention
dataclass that holds the ~45 post-normalization knobs that drive the
pipeline. Its :meth:`~picmaker.options.PicmakerOptions.validate`
method runs every mutex / value-validity check that does not depend
on raw argparse fields:
* ``hst`` + ``bands`` is rejected (HST mosaic mode consumes every
detector).
* ``frame`` + ``size`` is rejected (both specify output dimensions).
* ``frame`` + ``wrap_ratio`` is rejected (incompatible layout
decisions).
* ``display_upward`` + ``display_downward`` is rejected.
* ``twobytes`` requires a TIFF extension and rejects any
``filter_name`` other than ``'NONE'``.
* ``pds3_label_method`` must be one of the values in
:data:`~picmaker.options.PDS3_LABEL_METHODS`
(``'strict'``, ``'loose'``, ``'compound'``, ``'fast'``); the value is
forwarded as :class:`pdsparser.PdsLabel`'s ``method=`` argument when
a PDS3 ``.LBL`` is parsed.
The CLI's :func:`!picmaker.cli._normalize_and_validate` does a few
more checks that are CLI-specific (band/bands mismatch,
``--scale`` + ``--wscale``, ``--overlap`` + ``--overlaps``,
``--movie`` + ``--versions``) because those operate on raw flags that
get collapsed before the dataclass is built. Adding a new mutex rule
that applies to both surfaces should go in
:meth:`~picmaker.options.PicmakerOptions.validate`.
The reader cascade
~~~~~~~~~~~~~~~~~~
:func:`picmaker.io.read_one_image_array` is the single-file reader.
It tries every supported format in turn (pickle → numpy → VICAR →
FITS → PIL → PDS3) and returns a :class:`~picmaker.io.ReadResult`
triple. Each branch catches its specific exception types so an
unrecognized file falls through to the next; the cascade-end
:class:`OSError` is chained from a :class:`ExceptionGroup` that
carries every per-reader failure for diagnostic purposes.
The FITS branch sniffs the first 9 bytes for ``b'SIMPLE ='`` before
calling :func:`astropy.io.fits.open` so that wrong-extension files do
not trigger astropy's expensive parser, and so that warnings raised
from inside :func:`astropy.io.fits.open` are converted to exceptions
by :class:`warnings.catch_warnings` + ``filterwarnings('error')`` and
swallowed at the branch boundary.
:func:`picmaker.io.read_image_array` is the multi-file wrapper: it
delegates to :func:`~picmaker.io.read_one_image_array` per file and
stacks the resulting arrays along the band axis with
:func:`numpy.vstack`. The combined result inherits the
``default_is_up`` and ``filter_info`` of the first file.
:func:`picmaker.io.read_pds_labeled_image_array` handles the PDS3
label / detached-data case. Pointer resolution lives here:
``^IMAGE = 2`` (attached integer offset), ``^IMAGE = "data.dat"``
(detached, full file), and ``^IMAGE = ("data.dat", 3)`` (detached
with record offset) are all distinct branches.
:func:`picmaker.io.read_pil` and :func:`picmaker.io.read_array` are
the Pillow-side helpers used by PIL-readable inputs and by
:func:`~picmaker.io.read_one_image_array`'s PIL branch.
Path planning
~~~~~~~~~~~~~
:func:`picmaker.io.get_outfile` derives the output file path for one
input. It honors four ``replace=`` policies (``'all'`` — silent
overwrite; ``'none'`` — return ``''`` to signal the loop should
skip; ``'warn'`` — overwrite and emit :class:`UserWarning`;
``'error'`` — raise :class:`OSError`). It creates the parent
directory tree if it does not already exist.
:func:`picmaker.pipeline.find_common_path` derives the recursive
output tree's root by calling :func:`os.path.commonpath` over the
input directories. The legacy hand-rolled version of this function
used ``/`` as a literal separator and was wrong on Windows; the
current implementation handles platform separators correctly and
returns ``''`` when the inputs share only the root.
Per-image pipeline
~~~~~~~~~~~~~~~~~~
:func:`picmaker.pipeline.images_to_pics` runs the per-image pipeline
shown in the flowchart above. The body is now a thin loop that builds
a :class:`~picmaker.options.PicmakerOptions`, backfills the legacy
``None``-means-default kwargs, and delegates each filename to
:func:`!picmaker.pipeline._process_one_image`. That helper runs the
following phases for one input file:
1. Build the output path (:func:`~picmaker.io.get_outfile`); skip if
``replace='none'`` returned ``''``.
2. Read the array (:func:`~picmaker.io.read_image_array`), with PDS3
detached-label pointer resolution delegated to
:func:`!picmaker.pipeline._pds3_resolve_pointer`. The caller's
``reuse`` tuple short-circuits the read for the single-file batches
that :func:`process_images` builds per ``option_dict``.
3. If ``hst=True`` and the instrument is ACS/WFC or WFPC2, dispatch to
:func:`!picmaker.pipeline._hst_mosaic_rgb` for the per-detector
stack-and-mosaic flow.
4. Otherwise: slice (:func:`~picmaker.geometry.slice_array`),
optionally fill zebra stripes
(:func:`~picmaker.enhance.fill_zebra_stripes`), compute limits
(:func:`~picmaker.enhance.get_limits`), apply the colormap
(:func:`~picmaker.enhance.apply_colormap`).
5. Apply the orientation override
(:func:`~picmaker.geometry.rotate_array_rgb`) and gamma
(:func:`~picmaker.enhance.apply_gamma`).
6. Convert to a PIL image (:func:`~picmaker.pil_utils.array_to_pil`),
apply the PIL filter (:func:`~picmaker._filters.filter_image`),
resize (:func:`~picmaker.geometry.resize_image`), optionally wrap
(:func:`~picmaker.geometry.wrap_image`), optionally pad
(:func:`~picmaker.geometry.pad_image`).
7. Write (:func:`~picmaker.pil_utils.write_pil`), which dispatches
16-bit output to :func:`picmaker.tiff16.WriteTiff16` and
everything else to :meth:`PIL.Image.Image.save`.
The function returns ``(low, high, reuse)`` so callers (or the
``--movie`` second pass) can either consume the limits or replay the
read.
:func:`!picmaker.pipeline._hst_mosaic_rgb` itself further delegates
the panel-assembly geometry to two private helpers,
:func:`!picmaker.pipeline._hst_wfpc2_mosaic` (four detectors,
PC1/WF2/WF3/WF4 in a 2x2 quadrant) and
:func:`!picmaker.pipeline._hst_acs_panel_mosaic` (two detectors,
WFC1 above and WFC2 below). Each helper is unit-tested directly in
:file:`tests/test_pipeline_helpers.py`.
:func:`picmaker.pipeline.process_images` is the thin loop that drives
:func:`~picmaker.pipeline.images_to_pics` per file; its only real
job is the movie-mode two-pass dance described above.
Enhancement helpers
~~~~~~~~~~~~~~~~~~~
:func:`picmaker.enhance.get_limits` is the most option-heavy function
in the codebase. It supports four ways of choosing the stretch range
that can be combined:
* Explicit ``limits=(lo, hi)`` — passed through unchanged.
* ``percentiles=(lo%, hi%)`` — uses :func:`numpy.histogram` over the
valid pixels and linear interpolation to find the corresponding DN
values.
* ``trim=N`` — drop ``N`` pixels from each edge before computing.
* ``trim_zeros=True`` — peel all-zero exterior rows and columns
before computing.
* ``footprint=D`` — apply a circular median filter (footprint
diameter ``D``) and tighten the limits to the filter output.
:func:`picmaker.enhance.apply_colormap` maps a 2-D stretched array to
a 3-D RGB array using either a named hyphen-separated colormap (e.g.
``'red-blue'``), a list of ``(R, G, B)`` tuples, or the per-instrument
tint from :func:`picmaker.color.tinted_colormap`. It also handles the
out-of-range and invalid-pixel highlight colors.
:func:`picmaker.enhance.apply_gamma` is the final power-law correction
(``array ** gamma``).
:func:`picmaker.enhance.fill_zebra_stripes` cleans up leading- and
trailing-zero artifacts in compressed spacecraft images. The
implementation is currently a Python pixel loop; vectorization is
tracked in `issue #18
`__.
Geometry helpers
~~~~~~~~~~~~~~~~
:func:`picmaker.geometry.slice_array` takes the raw 3-D
``(bands, lines, samples)`` array and returns a 2-D band-averaged
array plus an optional invalid-pixel mask. It honors ``samples``,
``lines``, ``bands``, ``valid``, and ``crop`` slice arguments.
:func:`picmaker.geometry.crop_array` strips constant-value borders
(typically ``crop=0`` for zero-padded fields). It returns the input
unchanged if the whole array equals the crop value.
:func:`picmaker.geometry.rotate_array_rgb` applies the
``--rotate {fliplr,fliptb,rot90,rot180,rot270}`` choice and the
``display_upward`` override.
:func:`picmaker.geometry.get_size` is the resize planner. It returns
``(unwrapped_size, wrapped_size, sections, wrap_axis)``; the caller
uses ``unwrapped_size`` to resize the image and the remaining three
fields to wrap it (when ``sections > 1``).
:func:`picmaker.geometry.resize_image`,
:func:`picmaker.geometry.wrap_image`, and
:func:`picmaker.geometry.pad_image` execute the plan against a PIL
image. :func:`~picmaker.geometry.resize_image` upscales with
``NEAREST`` and downscales with ``LANCZOS`` so the output is
pixel-art-friendly for small inputs and Lanczos-smoothed for large
ones.
Color, filter, and PIL bridges
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:func:`picmaker.color.tinted_colormap` is the entry point for
per-filter tinting. It normalizes HST's ``CL1`` / ``CL2`` /
``CLEAR*`` / ``N/A`` filter-tuple quirks, picks the right instrument
module via :func:`picmaker.instruments.lookup`, and delegates to its
``tint_for`` callable (e.g.
:func:`picmaker.instruments.cassini.tint_for`).
:func:`picmaker._filters.filter_image` applies one of the
:data:`picmaker._filters.FILTER_DICT` PIL presets to a PIL image,
or raises :class:`KeyError` if the case-folded filter name is not in
the dict.
:func:`picmaker.pil_utils.array_to_pil`,
:func:`picmaker.pil_utils.pil_to_array`, and
:func:`picmaker.pil_utils.write_pil` are the three numpy ↔ PIL
bridges. :func:`~picmaker.pil_utils.write_pil` dispatches 16-bit
output (list-of-three ``'I'``-mode images, or a single ``'I'``-mode
image) through :func:`picmaker.tiff16.WriteTiff16` and the 8-bit
path through :meth:`PIL.Image.Image.save`.