"""
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"