affmae / scripts /render_examples.py
smerkd's picture
AFF-MAE ZeroGPU demo: segmentation, reconstruction, token layout, batch
8065faa verified
Raw
History Blame Contribute Delete
15.8 kB
#!/usr/bin/env python
"""Montage of qualitative examples across the pretraining and finetuned models.
Regenerates docs/assets/affmae_512_examples.png:
python scripts/render_examples.py \
--config configs/aff_base_finetune_512_fpw.yaml \
--checkpoint weights/segmentation/fpw_aff_base_ft_512_slits_pgbmi.pth \
--pretrain-config configs/aff_base_pretrain_0.4ds_0.5mask_last_local.yaml \
--pretrain-checkpoint weights/pretrain/ckpt_epoch_399_affmae_fpw.pth \
--samples docs/assets/sample{1,2,3,4}.png \
--output docs/assets/affmae_512_examples.png
One row per sample, one column per panel in ``--columns``. Seeded per sample, so
the command above reproduces the committed asset byte-for-byte.
Note:
Reconstruction panels use Perlin masking, matching pretraining
(``forward`` -> ``_forward_internal`` -> ``mask_and_embed``). The token
positions come from the reconstruction result, not a second
``token_layout`` call: that would draw a fresh mask, and only ~50% of the
tokens would land on a patch the masked image shows as visible.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import torch # noqa: E402
from affmae.config import load_config # noqa: E402
from affmae.inference import AFFMAE # noqa: E402
from affmae.utils.paths import default_plot_path # noqa: E402
from affmae.viz import ( # noqa: E402
PAPER,
class_overlay,
draw_token_positions,
to_display_image,
)
#: Stage banners. The two training stages the figure contrasts.
STAGE_1 = "Stage 1: MAE pretraining"
STAGE_2 = "Stage 2: Supervised finetune"
#: Panel name -> (column title, needs the pretraining model, stage banner).
#: The banner groups columns under a training stage; "" means no banner, and
#: consecutive columns sharing one get a single banner spanning them with dashed
#: rules at the boundaries.
PANELS = {
"input": ("Input", False, ""),
"masked": ("Perlin-masked input", True, STAGE_1),
"tokens_sparse": ("Sparse tokens, encoder stage {sparse_stage}", True, STAGE_1),
"reconstruction": ("Reconstruction", True, STAGE_1),
"tokens": ("Final stage\ntoken locations", False, STAGE_2),
"truth": ("Ground truth", False, STAGE_2),
"prediction": ("Prediction", False, STAGE_2),
}
#: The sparse-token panel is available but not shown by default: the masked input
#: and the reconstruction already carry the pretraining story, and a token panel
#: on both sides of the figure invites a comparison between a sparse stage-2
#: layout and a dense final-stage one that is not like-for-like.
DEFAULT_COLUMNS = ("input", "masked", "reconstruction", "tokens", "prediction")
def column_groups(columns):
"""Contiguous runs of columns sharing a banner.
Returns:
list of ``(banner, first_index, last_index)``, in column order. A run is
broken by any change of banner, so reordering ``--columns`` cannot
produce a banner that spans a column belonging to another stage.
"""
groups = []
for index, name in enumerate(columns):
banner = PANELS[name][2]
if groups and groups[-1][0] == banner:
groups[-1][2] = index
else:
groups.append([banner, index, index])
return [tuple(g) for g in groups]
def draw_stage_banners(fig, axes, columns, groups, viz, rows):
"""Write the stage banners and the dashed rules between stages.
Called after ``tight_layout``, because it reads final axes positions. Works
in figure coordinates, so ``bbox_inches="tight"`` crops around the banners
instead of clipping them.
"""
from matplotlib.lines import Line2D
top = max(axes[0][c].get_position().y1 for c in range(len(columns)))
bottom = min(axes[rows - 1][c].get_position().y0 for c in range(len(columns)))
# Place the banner above the *rendered* column titles rather than above the
# axes plus a guess. Estimating from font points broke as soon as a title
# wrapped -- "Final stage\ntoken locations" is two lines, and a pad sized for
# one put the banner on top of it. Ask matplotlib where the text actually
# ended up instead.
fig.canvas.draw()
inverse = fig.transFigure.inverted()
title_top = top
for column in range(len(columns)):
title = axes[0][column].title
if not title.get_text():
continue
box = inverse.transform(title.get_window_extent(fig.canvas.get_renderer()))
title_top = max(title_top, box[1][1])
# One line of the banner's own font, as breathing room above the titles.
pad = (title_top - top) + (viz.font_size + 6) * 1.1 / (72.0 * fig.get_size_inches()[1])
for banner, first, last in groups:
if not banner:
continue
left = axes[0][first].get_position().x0
right = axes[0][last].get_position().x1
fig.text((left + right) / 2.0, top + pad, banner,
ha="center", va="bottom",
fontsize=viz.font_size + 6, fontweight="bold")
for previous, following in zip(groups, groups[1:]):
gap_left = axes[0][previous[2]].get_position().x1
gap_right = axes[0][following[1]].get_position().x0
x = (gap_left + gap_right) / 2.0
fig.add_artist(Line2D([x, x], [bottom, top + pad * 1.9],
transform=fig.transFigure,
color="0.35", linestyle="--", linewidth=2.0))
def load_truth(mask_dir, stem, num_classes):
"""Load a multi-channel mask and flatten it the way training does.
Mirrors ``EMDatasetMultiClass.__getitem__``: each channel is binarized at 10
and a pixel takes ``channel_position + 1``, so later channels win on overlap.
"""
import glob
import tifffile
matches = glob.glob(os.path.join(mask_dir, stem + ".*"))
if not matches:
raise FileNotFoundError(
f"no mask for {stem!r} in {mask_dir}. Ground truth needs a mask "
f"whose filename stem matches the image.")
mask = tifffile.imread(matches[0])
if mask.ndim == 4:
mask = mask[:, :, :, 0]
if mask.ndim == 2:
return torch.from_numpy(mask.astype(np.int64))
labels = np.zeros(mask.shape[1:], dtype=np.int64)
for position in range(min(mask.shape[0], num_classes - 1)):
labels[mask[position] > 10] = position + 1
return torch.from_numpy(labels)
def resize_labels(labels, shape):
"""Nearest-neighbour resize of a label map to ``shape``.
Masks are stored at the microscope's native resolution while predictions come
back at ``img_size``; overlaying the two unresized makes matplotlib size the
axes to whichever imshow came last, shrinking the background into a corner.
Nearest, not bilinear: interpolating class indices invents classes that were
never annotated.
"""
if tuple(labels.shape) == tuple(shape):
return labels
resized = torch.nn.functional.interpolate(
labels[None, None].float(), size=tuple(shape), mode="nearest")
return resized[0, 0].long()
def build_parser():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True,
help="Finetuning config.")
parser.add_argument("--checkpoint", required=True,
help="Finetuned segmentation checkpoint.")
parser.add_argument("--pretrain-config", default=None,
help="Pretraining config; needed by the MAE panels.")
parser.add_argument("--pretrain-checkpoint", default=None,
help="Pretraining checkpoint; needed by the MAE panels.")
parser.add_argument("--samples", nargs="+", required=True,
help="Image paths, one row each.")
parser.add_argument("--columns", default=",".join(DEFAULT_COLUMNS),
help=f"comma-separated, from {sorted(PANELS)}")
parser.add_argument("--sparse-stage", type=int, default=2,
help="1-based encoder stage for the optional "
"tokens_sparse panel, which is off by default.")
parser.add_argument("--mask-ratio", type=float, default=0.5,
help="Fraction of patches hidden from the MAE.")
parser.add_argument("--masks", default=None,
help="Mask directory, required by the truth column.")
parser.add_argument("--seed", type=int, default=0,
help="Seeds the Perlin mask, so the figure is "
"reproducible. Pass -1 to leave the RNG alone.")
parser.add_argument("--output", default=None)
parser.add_argument("--device", default=None)
return parser
def main():
args = build_parser().parse_args()
columns = [name.strip() for name in args.columns.split(",") if name.strip()]
unknown = [name for name in columns if name not in PANELS]
if unknown:
raise SystemExit(f"unknown column(s) {unknown}; choose from {sorted(PANELS)}")
if "truth" in columns and not args.masks:
raise SystemExit("--columns includes 'truth', so --masks is required.")
needs_mae = any(PANELS[name][1] for name in columns)
if needs_mae and not (args.pretrain_config and args.pretrain_checkpoint):
wanted = [n for n in columns if PANELS[n][1]]
raise SystemExit(
f"column(s) {wanted} come from the MAE, so --pretrain-config and "
f"--pretrain-checkpoint are both required.")
if not 0.0 < args.mask_ratio < 1.0:
raise SystemExit(
f"--mask-ratio must be in (0, 1), got {args.mask_ratio}: nothing "
f"masked leaves the decoder no queries, everything masked leaves "
f"the encoder no input.")
config = load_config(args.config)
segmenter = AFFMAE.from_checkpoint(args.checkpoint, config=config,
device=args.device)
print(f"segmentation: {args.checkpoint} on {segmenter.device} "
f"({sorted(segmenter.capabilities)})")
mae = None
if needs_mae:
mae = AFFMAE.from_checkpoint(args.pretrain_checkpoint,
config=args.pretrain_config,
device=args.device)
print(f"pretraining: {args.pretrain_checkpoint} on {mae.device} "
f"({sorted(mae.capabilities)})")
if not mae.can_reconstruct:
raise SystemExit(
f"{args.pretrain_checkpoint} has no MAE head, so it cannot "
f"produce the masked/reconstruction panels.")
viz = PAPER
stage_index = args.sparse_stage - 1
rows = []
for index, path in enumerate(args.samples):
# Re-seed per sample so a row's mask does not depend on how many rows
# precede it -- otherwise dropping one sample changes all the others.
if args.seed >= 0:
torch.manual_seed(args.seed + index)
row = {"stem": os.path.splitext(os.path.basename(path))[0]}
row["result"] = segmenter.segment(path)
_, dense = segmenter.token_layout(path)
row["dense_tokens"] = dense[-1]
if mae is not None:
# One pass. Taking the tokens from a second call to
# token_layout(mask_ratio=...) drew a *fresh* Perlin mask, so the
# figure showed one mask's tokens over another mask's image -- only
# ~50% of them landed on a visible patch, which is chance. The
# reconstruction result carries the tokens from its own pass, so
# they and the masked image agree by construction (measured: 100%).
row["recon"] = mae.reconstruct(path, mask_ratio=args.mask_ratio)
if "tokens_sparse" in columns:
sparse = row["recon"].locations
if not sparse:
raise SystemExit(
f"{args.pretrain_checkpoint} exposes no per-stage token "
f"positions, so the sparse-token panel cannot be drawn.")
if not -len(sparse) <= stage_index < len(sparse):
raise SystemExit(
f"--sparse-stage {args.sparse_stage} is out of range; "
f"the encoder has {len(sparse)} stages.")
row["sparse_tokens"] = sparse[stage_index]
rows.append(row)
counts = row["result"].class_pixel_counts
print(f" {row['stem'][:44]}: classes {sorted(counts)}, "
f"dense {row['dense_tokens'].shape[0]} tokens"
+ (f", sparse {row['sparse_tokens'].shape[0]}"
if "sparse_tokens" in row else ""))
n_rows, n_cols = len(rows), len(columns)
size = viz.figsize_per_cell
fig, axes = plt.subplots(n_rows, n_cols,
figsize=(size * n_cols, size * n_rows),
squeeze=False)
for r, row in enumerate(rows):
result = row["result"]
dense_bg = to_display_image(result.image, viz)
for c, column in enumerate(columns):
ax = axes[r][c]
if column == "input":
ax.imshow(dense_bg, cmap=viz.cmap, vmin=0, vmax=1)
elif column == "masked":
ax.imshow(to_display_image(row["recon"].masked, viz),
cmap=viz.cmap, vmin=0, vmax=1)
elif column == "reconstruction":
# reconstructions[-1] is the finest head, and AFFMAE.reconstruct
# already stitches the visible original patches back in, so only
# the masked region is model output.
ax.imshow(to_display_image(row["recon"].reconstructions[-1], viz),
cmap=viz.cmap, vmin=0, vmax=1)
elif column == "tokens_sparse":
ax.imshow(draw_token_positions(
row["recon"].masked, row["sparse_tokens"],
config.patch_size, viz))
elif column == "tokens":
ax.imshow(draw_token_positions(
result.image, row["dense_tokens"], config.patch_size, viz))
else:
ax.imshow(dense_bg, cmap=viz.cmap, vmin=0, vmax=1)
if column == "prediction":
ax.imshow(class_overlay(result.labels, result.num_classes, viz))
elif column == "truth":
truth = load_truth(args.masks, row["stem"], result.num_classes)
ax.imshow(class_overlay(
resize_labels(truth, result.labels.shape),
result.num_classes, viz))
ax.set_xticks([])
ax.set_yticks([])
if r == 0:
ax.set_title(
PANELS[column][0].format(sparse_stage=args.sparse_stage),
fontsize=viz.font_size + 2)
if c == 0:
label = row["stem"]
if len(label) > 24: # microscope filenames run past 60 chars
label = label[:11] + "..." + label[-10:]
ax.set_ylabel(label, fontsize=viz.font_size)
fig.tight_layout()
draw_stage_banners(fig, axes, columns, column_groups(columns), viz, n_rows)
output = args.output or default_plot_path("affmae_examples.png")
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
fig.savefig(output, dpi=viz.dpi, bbox_inches="tight")
plt.close(fig)
print(f"Wrote {output} ({n_rows} rows x {n_cols} cols: {', '.join(columns)})")
if __name__ == "__main__":
main()