Source code for multicolorfits._overview

"""
Agent- and human-facing capability map for multicolorfits.

This module is the SINGLE SOURCE OF TRUTH for:

* ``mcf.overview()`` / ``mcf.recipes()`` — runtime orientation in a session,
* ``llms.txt`` / ``llms-full.txt`` — static files an AI agent ingests
  (generated by ``scripts/make_llms_txt.py``; ``tests/test_overview.py``
  fails if they drift from this module).

Everything below — the mental model, the conventions (common first-attempt
mistakes), and the runnable recipes — lives here so the docs an agent reads
can never disagree with the code it runs.  Edit recipes HERE, then run
``python scripts/make_llms_txt.py`` to refresh the generated files.
"""

from __future__ import annotations

import inspect
from collections import namedtuple
from typing import Any

from . import __version__

# --------------------------------------------------------------------------
# Mental model
# --------------------------------------------------------------------------

LAYER_FIRST = """\
## The colorize-then-combine model

multicolorfits is LAYER-FIRST.  You build one *colorized layer* per image
band, then add the layers together into a single multicolor image.  Every
layer goes through the same three steps:

    import multicolorfits as mcf
    grey = mcf.to_grey_rgb(data, rescalefn='asinh', min_max=[0, 5])  # 1. stretch → grey RGB
    layer = mcf.colorize_image(grey, '#C11B17', colorintype='hex')   # 2. tint it a color
    rgb = mcf.combine_multicolor([layer_a, layer_b, ...], gamma=2.2)  # 3. add layers up

Step 1 (`to_grey_rgb`) maps raw intensities into a [0,1] greyscale *RGB cube*
(shape ``(ny, nx, 3)``).  Step 2 (`colorize_image`) tints that grey cube a hue.
Step 3 (`combine_multicolor`) sums the tinted layers and renormalizes.

For interactive / stateful work (and the GUIs), use `McfSession`: it holds N
panels (one per band) plus compose settings, and `render_combined()` runs the
same pipeline for you.  `combine_layers` / `combine_from_files` are one-call
wrappers over the whole pipeline for arrays / FITS files.
"""

# --------------------------------------------------------------------------
# Conventions (the common first-attempt mistakes)
# --------------------------------------------------------------------------

CONVENTIONS = (
    "colorize_image() takes a GREY RGB CUBE (shape (ny,nx,3) from "
    "to_grey_rgb()), NOT a raw 2D data array. Passing 2D data raises a "
    "TypeError that tells you to call to_grey_rgb() first.",
    "combine_multicolor() takes a LIST of colorized layers, not a single "
    "array and not a list of raw 2D arrays. Each element is an (ny,nx,3) "
    "layer from colorize_image().",
    "colorintype= selects how a color is given: 'hex' ('#FF0000'), 'rgb' "
    "([255,0,0], 0-255), or 'hsv' ([h,s,v], each 0-1). The default is 'hex'.",
    "gamma is applied consistently: use the SAME gamma (default 2.2) in "
    "to_grey_rgb / colorize / combine so layers stack predictably.",
    "Intensity limits: to_grey_rgb(scaletype='abs', min_max=[lo,hi]) uses "
    "data units; scaletype='perc', min_max=[1,99.5] uses percentiles. rescalefn "
    "is the stretch ('linear','sqrt','asinh','log',...).",
    "Backgrounds: classic combine_multicolor makes emission on BLACK. For a "
    "white/paper background use inverse=True (hue-complement look) OR "
    "combine_multicolor_alpha(..., background='white') (keeps true hues). Do "
    "NOT expect a transparent/white compose to reproduce the black-background "
    "colors exactly — the alpha compositor shifts/brightens them.",
    "FITS arrays are origin='lower' (row 0 at the bottom). Display with "
    "plt.imshow(rgb, origin='lower'). save_transparent_cutout() writes PNGs "
    "flipped so ordinary image viewers show the same sky orientation.",
    "Perceptual alternatives to the additive RGB combine: "
    "combine_multicolor_colorspace(layers, colorspace='lab', blend='screen') "
    "keeps hues from washing to white. 'lab' is well-tested; 'hsv'/'hsl' are "
    "experimental.",
    "Optional features gate on extras: reproject (align/reproject), "
    "skyplothelper (compass/beam/scale-bar overlays), PySide6 (mcf.gui_qt), "
    "fastapi+uvicorn (mcf.gui). Core pipeline needs only numpy/astropy/"
    "scipy/matplotlib/scikit-image.",
    "Colors that survive color-blindness / greyscale: mcf.suggest_colors(n) "
    "and mcf.list_palettes() give vetted palettes; McfSession.colorblind_report() "
    "flags clashes.",
)

# --------------------------------------------------------------------------
# Recipes
# --------------------------------------------------------------------------

Recipe = namedtuple("Recipe", "task category functions code notes")

RECIPES = (
    # ---- basics -----------------------------------------------------------
    Recipe(
        task="Colorize and combine two bands (the core pipeline)",
        category="basics",
        functions=("to_grey_rgb", "colorize_image", "combine_multicolor"),
        code="""\
import multicolorfits as mcf

# data_r, data_b: 2D numpy arrays (your two bands, same shape)
grey_r = mcf.to_grey_rgb(data_r, rescalefn='asinh', scaletype='perc', min_max=[1, 99.5])
grey_b = mcf.to_grey_rgb(data_b, rescalefn='asinh', scaletype='perc', min_max=[1, 99.5])
layer_r = mcf.colorize_image(grey_r, '#E4002B', colorintype='hex')   # red
layer_b = mcf.colorize_image(grey_b, '#0088FF', colorintype='hex')   # blue
rgb = mcf.combine_multicolor([layer_r, layer_b], gamma=2.2)          # (ny,nx,3) in [0,1]""",
        notes="Add more bands by appending more colorized layers to the list. "
              "colorintype='hex'|'rgb'|'hsv'.",
    ),
    Recipe(
        task="One-call combine from a list of arrays",
        category="basics",
        functions=("combine_layers",),
        code="""\
import multicolorfits as mcf

# combine_layers runs stretch + colorize + combine for you.
rgb = mcf.combine_layers(
    [data_r, data_g, data_b],
    colors=['#E4002B', '#33CC33', '#0088FF'],
    stretches=['asinh', 'asinh', 'asinh'],
    gamma=2.2)""",
        notes="Pass min_maxes=[[vmin,vmax], ...] for absolute data-unit limits "
              "per layer (not percentiles). colorspace='lab' switches to "
              "perceptual compositing; inverse=True gives a white-background look.",
    ),
    Recipe(
        task="Combine straight from FITS files (optionally aligned)",
        category="basics",
        functions=("combine_from_files",),
        code="""\
import multicolorfits as mcf

# fits_r, fits_g, fits_b: paths to FITS files.
rgb = mcf.combine_from_files(
    [fits_r, fits_g, fits_b],
    colors=['#E4002B', '#33CC33', '#0088FF'],
    gamma=2.2)""",
        notes="Reproject onto a common grid with align_reference=0 (index) or "
              "align_optimal=True (needs the 'reproject' extra).",
    ),
    # ---- scaling ----------------------------------------------------------
    Recipe(
        task="Choose an intensity stretch and limits",
        category="scaling",
        functions=("to_grey_rgb", "rescale_image", "zscale_limits"),
        code="""\
import multicolorfits as mcf

# Absolute data-unit limits:
grey = mcf.to_grey_rgb(data, rescalefn='log', scaletype='abs', min_max=[0.0, 12.0], gamma=2.2)
# Percentile limits (robust to outliers):
grey = mcf.to_grey_rgb(data, rescalefn='sqrt', scaletype='perc', min_max=[1, 99.5])
# zscale (IRAF-style) limits for a raw array:
vmin, vmax = mcf.zscale_limits(data)""",
        notes="rescalefn: 'linear','sqrt','squared','log','asinh','sinh','power'. "
              "scaletype: 'abs' (data units) or 'perc' (percentiles).",
    ),
    # ---- colorspaces ------------------------------------------------------
    Recipe(
        task="Perceptual (Lab) compositing instead of additive RGB",
        category="colorspaces",
        functions=("combine_multicolor_colorspace",),
        code="""\
import multicolorfits as mcf

# layers: list of colorized (ny,nx,3) layers from colorize_image()
rgb = mcf.combine_multicolor_colorspace(
    layers, colorspace='lab', blend='screen', gamma=2.2)""",
        notes="colorspace='lab' is well-tested and keeps hues from washing out; "
              "blend='screen'|'max'|'sum'. 'hsv'/'hsl' spaces are experimental.",
    ),
    # ---- backgrounds ------------------------------------------------------
    Recipe(
        task="White / paper background (keep true hues)",
        category="backgrounds",
        functions=("combine_multicolor_alpha", "composite_over_background"),
        code="""\
import multicolorfits as mcf

# Composite the layers over white without the legacy hue-flipping inverse:
rgb = mcf.combine_multicolor_alpha(layers, background='white', mode='rgb', gamma=2.2)
# Or flatten an already-combined black-background image onto a color:
rgb_on_slide = mcf.composite_over_background(combined, background='#12243A')""",
        notes="mode='ryb' mixes like paint (yellow+blue→green); mode='cmyk' like "
              "print inks; background=None returns transparent RGBA.",
    ),
    Recipe(
        task="Classic inverse (white background, hue-complement look)",
        category="backgrounds",
        functions=("combine_multicolor",),
        code="""\
import multicolorfits as mcf

rgb_inverse = mcf.combine_multicolor(layers, gamma=2.2, inverse=True)""",
        notes="inverse=True is the classic look (also flips hues). For true-hue "
              "white backgrounds prefer combine_multicolor_alpha(background='white').",
    ),
    # ---- session ----------------------------------------------------------
    Recipe(
        task="Stateful editing with McfSession",
        category="session",
        functions=("McfSession", "McfSession.render_combined"),
        code="""\
import multicolorfits as mcf

s = mcf.McfSession(n_panels=3)
s.load_files([fits_r, fits_g, fits_b],
             colors=['#E4002B', '#33CC33', '#0088FF'],
             labels=['R', 'G', 'B'])
for p in s.panels:
    p.stretch = 'asinh'
    p.set_percentiles(1, 99.5)
s.compose.combine_mode = 'rgb'      # or 'lab'
s.compose.gamma = 2.2
rgb = s.render_combined()""",
        notes="set_data(array, header) instead of load_files for in-memory arrays. "
              "s.compose.combine_background = 'black'|'white'.",
    ),
    Recipe(
        task="Save and reload a session (JSON)",
        category="session",
        functions=("McfSession.save_state", "McfSession.load_state"),
        code="""\
import multicolorfits as mcf

s.save_state('my_session.json')          # portable; references FITS by path
s2 = mcf.McfSession()
s2.load_state('my_session.json')
rgb = s2.render_combined()""",
        notes="s.export_script() emits a standalone Python script that recreates "
              "the current image with the plain pipeline API.",
    ),
    # ---- cutouts ----------------------------------------------------------
    Recipe(
        task="Transparent-sky cutout / stamp (PNG)",
        category="cutouts",
        functions=("make_transparent_cutout", "save_transparent_cutout"),
        code="""\
import multicolorfits as mcf

# combined: an (ny,nx,3) RGB image on a black background.
rgba = mcf.make_transparent_cutout(
    combined, crop='auto', pad=0.05,
    alpha_smooth=3, alpha_lo=45, alpha_hi=82, alpha_gamma=0.7)
mcf.save_transparent_cutout(rgba, 'stamp.png')   # written right-side-up for viewers""",
        notes="Higher alpha_lo/alpha_hi clear more faint outer junk; alpha_smooth "
              "blurs the matte so it follows whole structures. From a session use "
              "s.export_transparent_cutout(size=512, savepath='stamp.png').",
    ),
    Recipe(
        task="Preview a cutout over several backgrounds",
        category="cutouts",
        functions=("preview_cutout_on_backgrounds",),
        code="""\
import multicolorfits as mcf
import matplotlib.pyplot as plt

fig = mcf.preview_cutout_on_backgrounds(rgba)   # checker / dark / light
fig.savefig('cutout_preview.png', dpi=110)""",
        notes="Good for checking the alpha matte before you drop a stamp on slides.",
    ),
    # ---- wcs --------------------------------------------------------------
    Recipe(
        task="Crop a FITS image by pixel bounds",
        category="wcs",
        functions=("crop_image",),
        code="""\
import multicolorfits as mcf

cropped_data, cropped_header = mcf.crop_image(
    data, header, xbounds=[100, 400], ybounds=[120, 420])""",
        notes="crop_image_sky(data, header, ra_bounds, dec_bounds) crops in sky "
              "coordinates; crop_cube / crop_cube_sky handle 3D.",
    ),
    Recipe(
        task="Align / reproject bands onto a common grid",
        category="wcs",
        functions=("align_stack", "reproject_image"),
        code="""\
import multicolorfits as mcf

# Needs the 'reproject' extra:  pip install multicolorfits[reproject]
# images is a list of (array, header) pairs; all are reprojected onto the
# reference layer's grid.
aligned = mcf.align_stack(
    [(data_r, header), (data_g, header), (data_b, header)],
    reference=0)
data_list = [d for d, hdr in aligned]""",
        notes="reproject_image(data, header_from, header_to) reprojects one array. "
              "optimal=True finds a common grid covering all inputs.",
    ),
    # ---- figures ----------------------------------------------------------
    Recipe(
        task="Publication figure with WCS axes, legend, swatch",
        category="figures",
        functions=("make_combined_figure",),
        code="""\
import multicolorfits as mcf

s.compose.show_legend = True
s.compose.show_combo_swatch = True
fig = mcf.make_combined_figure(s, figsize=(7, 7))
fig.savefig('figure.png', dpi=150, facecolor=fig.get_facecolor())""",
        notes="make_component_mosaic(s) adds a strip of the individual colorized "
              "bands beside the combined hero image.",
    ),
    Recipe(
        task="Bare, image-only figure (press-release style)",
        category="figures",
        functions=("apply_bare_plot_style", "make_combined_figure"),
        code="""\
import multicolorfits as mcf

mcf.apply_bare_plot_style(s.compose, transparent=False)  # hide ticks/labels/legend
fig = mcf.make_combined_figure(s, figsize=(6, 6))
fig.savefig('bare.png', dpi=200, facecolor=fig.get_facecolor())""",
        notes="transparent=True also sets facecolor='none' for a transparent margin.",
    ),
    # ---- overlays ---------------------------------------------------------
    Recipe(
        task="Compass, beam, and scale-bar overlays",
        category="overlays",
        functions=("make_combined_figure", "overlays_available"),
        code="""\
import multicolorfits as mcf

# Needs the 'overlays' extra:  pip install multicolorfits[overlays]
if mcf.overlays_available():
    s.compose.show_compass = True
    s.compose.show_scale_bar = True
    s.compose.scale_bar_asec = 30.0     # arcsec
    fig = mcf.make_combined_figure(s, figsize=(7, 7))""",
        notes="Beam needs BMAJ/BMIN in the header (s.compose.show_beam = True). "
              "Overlays require skyplothelper.",
    ),
    # ---- palettes ---------------------------------------------------------
    Recipe(
        task="Pick colorblind-safe colors",
        category="palettes",
        functions=("suggest_colors", "list_palettes", "get_palette"),
        code="""\
import multicolorfits as mcf

colors = mcf.suggest_colors(3)          # vetted, distinguishable hexes
print(mcf.list_palettes())              # named palettes
pal = mcf.get_palette('pob', n=3)       # e.g. purple/orange/blue""",
        notes="McfSession.colorblind_report() flags clashing layer colors; "
              "McfSession.apply_palette(name) recolors all panels.",
    ),
    # ---- output -----------------------------------------------------------
    Recipe(
        task="Save the combined image as an RGB FITS cube",
        category="output",
        functions=("save_rgb_fits", "McfSession.save_rgb_fits"),
        code="""\
import multicolorfits as mcf

mcf.save_rgb_fits('combined_rgb.fits', combined, header)
# or from a session (keeps WCS + provenance):
s.save_rgb_fits('combined_rgb.fits')""",
        notes="For an ordinary PNG/JPG just use plt.imsave('out.png', combined[::-1]) "
              "(flip because FITS is origin-lower).",
    ),
)

# --------------------------------------------------------------------------
# Rendering helpers (shared by overview/recipes and the llms.txt generator)
# --------------------------------------------------------------------------


def _categories():
    seen = []
    for r in RECIPES:
        if r.category not in seen:
            seen.append(r.category)
    return seen


def _match(recipe: Recipe, query: str) -> bool:
    tokens = [t for t in query.lower().split() if t]
    haystack = " ".join([
        recipe.task, recipe.category, " ".join(recipe.functions), recipe.notes,
    ]).lower()
    return all(tok in haystack for tok in tokens)


def _render_recipe(recipe: Recipe) -> str:
    out = [f"# {recipe.task}  [{recipe.category}]", recipe.code]
    if recipe.notes:
        out.append(f"# Adjust: {recipe.notes}")
    return "\n".join(out)


def _render_overview() -> str:
    lines = [
        f"multicolorfits v{__version__} — colorize & combine FITS images "
        "into multicolor plots.",
        "",
        LAYER_FIRST.rstrip(),
        "",
        "## Conventions (common first-attempt mistakes)",
        "",
    ]
    for c in CONVENTIONS:
        lines.append(f"- {c}")
    lines += [
        "",
        f"## Task index ({len(RECIPES)} recipes — "
        "call mcf.recipes('<keyword>') for the code)",
        "",
    ]
    for cat in _categories():
        lines.append(f"  {cat}:")
        lines += [f"    - {r.task}" for r in RECIPES if r.category == cat]
    lines += [
        "",
        "Next: mcf.recipes('cutout'), mcf.recipes('lab'), mcf.recipes('session'); "
        "or mcf.overview(as_dict=True) for structured output.",
    ]
    return "\n".join(lines)


# --------------------------------------------------------------------------
# Public entry points
# --------------------------------------------------------------------------


[docs] def overview(query: str | None = None, *, as_dict: bool = False) -> Any: """ Print an orientation to multicolorfits — the colorize-then-combine model, conventions, and a task→function index. Parameters ---------- query : str, optional If given, defer to :func:`recipes` (print matching runnable recipes). as_dict : bool Return the structured catalog for tools / programmatic use instead of printing: ``{'layer_first', 'conventions', 'recipes': [{'task', 'category', 'functions', 'code', 'notes'}, ...]}``. Notes ----- Designed as the first call an agent (or newcomer) makes: ``import multicolorfits as mcf; mcf.overview()``. Examples -------- >>> import multicolorfits as mcf >>> mcf.overview() # doctest: +SKIP >>> mcf.overview('cutout') # doctest: +SKIP >>> cat = mcf.overview(as_dict=True) >>> sorted(cat) ['conventions', 'layer_first', 'recipes'] """ if as_dict: return { "layer_first": LAYER_FIRST, "conventions": list(CONVENTIONS), "recipes": [r._asdict() for r in RECIPES], } if query is not None: return recipes(query) print(_render_overview()) return None
[docs] def recipes(query: str | None = None) -> None: """ Print runnable task→code recipes. With no argument, list every recipe (task + category). With a *query* keyword (a task / function / topic), print the matching recipes' copy-paste code. Examples -------- >>> import multicolorfits as mcf >>> mcf.recipes() # the full menu # doctest: +SKIP >>> mcf.recipes('cutout') # transparent stamps # doctest: +SKIP >>> mcf.recipes('lab') # perceptual compositing # doctest: +SKIP """ if query is None: lines = [f"{len(RECIPES)} recipes " "(call mcf.recipes('<keyword>') for the code):", ""] for cat in _categories(): lines.append(f" {cat}:") lines += [f" - {r.task}" for r in RECIPES if r.category == cat] print("\n".join(lines)) return hits = [r for r in RECIPES if _match(r, query)] if not hits: cats = ", ".join(_categories()) print(f"No recipe matched {query!r}. Try a topic: {cats}.\n" f"Or run mcf.recipes() for the full menu.") return print("\n\n".join(_render_recipe(r) for r in hits))
# -------------------------------------------------------------------------- # llms.txt rendering # -------------------------------------------------------------------------- _DOCS_LINKS = [ "- [Quickstart / tutorials](https://multicolorfits.readthedocs.io/en/latest/tutorials/index.html)", "- [User guide](https://multicolorfits.readthedocs.io/en/latest/guide/index.html)", "- [Examples](https://multicolorfits.readthedocs.io/en/latest/examples/index.html)", "- [API reference](https://multicolorfits.readthedocs.io/en/latest/api/index.html)", ] def _llms_functions_block() -> str: """`inspect.signature` for every function named across the recipes.""" import multicolorfits as mcf names = [] for r in RECIPES: for fn in r.functions: base = fn.split(".")[0] if base not in names: names.append(base) lines = ["## Function signatures", ""] for name in names: obj = getattr(mcf, name, None) if obj is None: continue try: sig = str(inspect.signature(obj)) except (TypeError, ValueError): sig = "(...)" kind = "class" if inspect.isclass(obj) else "def" lines.append(f"{kind} {name}{sig}") return "\n".join(lines) def render_llms(full: bool = False) -> str: """ Render the ``llms.txt`` (``full=False``) or ``llms-full.txt`` (``full=True``) text from this catalog. Kept byte-for-byte in sync with the committed files by ``tests/test_overview.py``; regenerate with ``scripts/make_llms_txt.py``. """ parts = [ "# multicolorfits", "", f"> Colorize and combine any number of FITS image layers into " f"publication-quality multicolor astronomy figures (Python, v{__version__}). " f"In a session, `import multicolorfits as mcf; mcf.overview()` prints the " f"orientation below and `mcf.recipes('<keyword>')` prints these recipes.", "", LAYER_FIRST.rstrip(), "", "## Conventions (the common first-attempt mistakes)", "", ] parts += [f"- {c}" for c in CONVENTIONS] parts += ["", "## Recipes (runnable; your data as named placeholders)", ""] for cat in _categories(): parts.append(f"### {cat}") parts.append("") for r in RECIPES: if r.category != cat: continue parts += [f"**{r.task}**", "", "```python", r.code, "```"] if r.notes: parts.append(f"*Adjust:* {r.notes}") parts.append("") if full: parts += [_llms_functions_block(), ""] parts += ["## Docs", ""] parts += _DOCS_LINKS text = "\n".join(parts) return text.rstrip("\n") + "\n"