#!/usr/bin/env python3 """Approved offline finish coverage for new Sanctification stained-glass cards.""" from __future__ import annotations import argparse from dataclasses import dataclass import hashlib import json import math from pathlib import Path import numpy as np from PIL import Image, ImageFilter, __version__ as pillow_version RECIPE_PATH = Path(__file__).resolve().parent / 'recipes/raw-ridges-v1.json' RECIPE_SHA256 = '23331ce56a717edb4e6f85956d11fdd98397009c4e6dd230d8c334c77571dd18' PRINTINGS = ('normal', 'textless', 'borderless', 'boundless') def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def load_recipe() -> dict: if sha256(RECIPE_PATH) != RECIPE_SHA256: raise ValueError('raw-ridges-v1 recipe changed; create a new revision instead') return json.loads(RECIPE_PATH.read_text()) def _smooth(lo: float, hi: float, values: np.ndarray) -> np.ndarray: t = np.clip((values - lo) / (hi - lo), 0, 1) return t * t * (3 - 2 * t) def _shift(values: np.ndarray, dx: int, dy: int) -> np.ndarray: height, width = values.shape padded = np.pad(values, ((abs(dy), abs(dy)), (abs(dx), abs(dx))), mode='edge') return padded[abs(dy)+dy:abs(dy)+dy+height, abs(dx)+dx:abs(dx)+dx+width] def extract_raw_ridges(art: Image.Image) -> np.ndarray: """Return uint8 confidence, matching the approved raw-ridges.png precision. Operates at the supplied pixel scale. Production callers use the master canvas via prepare_illustration; small images are useful as behavior probes. """ r = load_recipe() line_art = art.convert('RGB').filter(ImageFilter.GaussianBlur(r['lineAnalysisSmoothingRadius'])) luminance = np.asarray(line_art.convert('L'), dtype=np.float32) / 255. darkness = 1 - _smooth(*r['darkLuminanceSmoothstep'], luminance) raw = np.zeros_like(luminance) for index in range(r['orientations']): angle = math.pi * index / r['orientations'] for radius in r['normalSampleRadii']: dx, dy = round(math.cos(angle)*radius), round(math.sin(angle)*radius) shoulders = np.minimum(_shift(luminance, dx, dy), _shift(luminance, -dx, -dy)) ridge = _smooth(*r['ridgeContrastSmoothstep'], shoulders-luminance) * darkness np.maximum(raw, ridge, out=raw) # Preserve the quantization used by the approved PNG input. No snapping. return np.rint(raw * 255).astype(np.uint8) @dataclass(frozen=True) class IllustrationCoverage: pane_weights: np.ndarray raw_ridges: np.ndarray recipe_id: str = 'raw-ridges-v1' @property def size(self) -> tuple[int, int]: height, width = self.raw_ridges.shape return width, height def prepare_illustration(art: Image.Image) -> IllustrationCoverage: """Analyze the approved fitted art once, then reuse it across printings.""" r = load_recipe() if art.size != tuple(r['canvas']): raise ValueError('Fit and review artwork on the 2000×2800 canvas before mask extraction') if 'A' in art.getbands() and np.any(np.asarray(art.getchannel('A')) != 255): raise ValueError('The master artwork must be fully opaque') art = art.convert('RGB') rgb = np.asarray(art, dtype=np.float32) / 255. value = rgb.max(axis=2) saturation = (value-rgb.min(axis=2)) / np.maximum(value, 1e-8) soft = np.asarray(Image.fromarray(np.rint(saturation*255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(r['saturationSmoothingRadius'])), dtype=np.float32) / 255. panes = r['paneCoverageFloor'] + r['paneCoverageRange'] * _smooth( *r['saturationWeightSmoothstep'], soft) return IllustrationCoverage(panes, extract_raw_ridges(art)) def _component_alpha(image: Image.Image, size: tuple[int, int], name: str) -> np.ndarray: if image.size != size: raise ValueError(f'{name} must share the artwork canvas and transform') if 'A' not in image.getbands(): raise ValueError(f'{name} must contain actual component alpha, not a flattened face or data mask') return np.asarray(image.getchannel('A')) / 255. def compile_finish(illustration: IllustrationCoverage, printing: str, overlay: Image.Image | None = None, text: Image.Image | None = None) -> Image.Image: """Compose a scalar master from this printing's actual overlay/text alpha. Normal: frame + backings overlay and text. Textless: frame-only overlay. Borderless: backing-only overlay and text. Boundless: neither component. """ if printing not in PRINTINGS: raise ValueError(f'Unknown printing: {printing}') requires_overlay = printing != 'boundless' requires_text = printing in ('normal', 'borderless') if (overlay is not None) != requires_overlay: raise ValueError(f'{printing} requires an overlay' if requires_overlay else 'Boundless has no overlay') if (text is not None) != requires_text: raise ValueError(f'{printing} requires rendered text' if requires_text else f'{printing} has no text') r = load_recipe() if illustration.recipe_id != r['id'] or illustration.size != tuple(r['canvas']): raise ValueError('Illustration coverage must use the approved recipe and master canvas') panes, ridges = illustration.pane_weights, illustration.raw_ridges if panes.shape != ridges.shape or ridges.dtype != np.uint8: raise ValueError('Invalid illustration coverage arrays') if not np.all(np.isfinite(panes)) or np.any((panes < 0) | (panes > 1)): raise ValueError('Pane coverage must be finite within [0, 1]') oa = 0. if overlay is None else _component_alpha(overlay, illustration.size, 'Overlay') if printing in ('normal', 'textless'): height, width = ridges.shape corners = ((0, 0), (0, width-1), (height-1, 0), (height-1, width-1)) if any(oa[y, x] != 1 for y, x in corners): raise ValueError('Framed printings require opaque overlay corners; geometry owns rounding') ta = 0. if text is None else _component_alpha(text, illustration.size, 'Text') protection = 1 - (1-oa)*(1-ta) confidence = ridges.astype(np.float32) / 255. coverage = np.rint(panes*(1-confidence)*(1-protection)*255).astype(np.uint8) return Image.fromarray(coverage) def export_finish(master: Image.Image, output: Path, name: str, printing: str) -> list[dict]: """Write the existing opaque [image-name]-mask.png data convention.""" if printing not in PRINTINGS: raise ValueError(f'Unknown printing: {printing}') if not name or Path(name).name != name or name in ('.', '..'): raise ValueError('Name must be a single card filename stem') r = load_recipe() if master.size != tuple(r['canvas']) or master.mode != 'L': raise ValueError('Expected a scalar 2000×2800 master') output.mkdir(parents=True, exist_ok=True) files = [] for width in r['exportWidths']: height = width*7//5 im = master if master.width == width else master.resize((width, height), Image.Resampling.BILINEAR) path = output / f'{name}-{printing}-{width}-mask.png' im.convert('RGBA').save(path) files.append({'path': str(path.resolve()), 'size': [width, height], 'sha256': sha256(path)}) return files def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--art', type=Path, required=True) parser.add_argument('--printing', choices=PRINTINGS, required=True) parser.add_argument('--overlay', type=Path) parser.add_argument('--text', type=Path) parser.add_argument('--name', required=True) parser.add_argument('--output', type=Path, required=True) args = parser.parse_args() try: art = Image.open(args.art) overlay = Image.open(args.overlay) if args.overlay else None text = Image.open(args.text) if args.text else None illustration = prepare_illustration(art) master = compile_finish(illustration, args.printing, overlay, text) exports = export_finish(master, args.output, args.name, args.printing) except (OSError, ValueError) as error: parser.error(str(error)) # Diagnostics are authoring inputs; they are not required runtime samplers. Image.fromarray(illustration.raw_ridges).save(args.output / f'{args.name}-raw-ridges.png') Image.fromarray(np.rint(illustration.pane_weights*255).astype(np.uint8)).save( args.output / f'{args.name}-pane-coverage.png') sources = {name: {'path': str(path.resolve()), 'sha256': sha256(path)} for name, path in [('art', args.art), ('overlay', args.overlay), ('text', args.text)] if path} report = {'status': 'passed', 'recipe': load_recipe()['id'], 'recipePath': str(RECIPE_PATH), 'recipeSha256': RECIPE_SHA256, 'moduleSha256': sha256(Path(__file__)), 'tools': {'numpy': np.__version__, 'pillow': pillow_version}, 'printing': args.printing, 'inputs': sources, 'exports': exports, 'review': {'structural': 'Canvas and component contract checked', 'static': 'Pending', 'movingLight': 'Pending', 'userApproval': 'Pending for this card'}} (args.output / f'{args.name}-{args.printing}-finish-validation.json').write_text(json.dumps(report, indent=2)+'\n') print(json.dumps({'recipe': report['recipe'], 'printing': args.printing, 'exports': exports}, indent=2)) if __name__ == '__main__': main()