Files
Sanctification/tools/card-production/finish_masks.py

180 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()