180 lines
9.3 KiB
Python
180 lines
9.3 KiB
Python
#!/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()
|