mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

prep.py

Language Python15.9 KB356 lines

skills/topaz-prepress/scripts/prep.py

  1. #!/usr/bin/env python3
  2. """
  3. Topaz pre-pass prepper.
  4. Analyzes raster logos/images, does NON-DESTRUCTIVE prep that genuinely helps a
  5. Topaz Gigapixel upscale (mode normalization, alpha split + defringe, lossless
  6. re-save, optional false-resolution pre-downscale), and writes a per-image
  7. recommendation report for which Topaz model + settings to use.
  8. Design rule: Topaz wants clean ORIGINAL signal. We do not pre-sharpen or
  9. pre-denoise here -- that destroys the detail Topaz relies on and double-stacks
  10. with Topaz's own processing. Python's job is structure/format hygiene + routing.
  11. Usage:
  12. python prep.py INPUT [INPUT ...] --out OUTDIR [--predownscale] [--zip-name NAME]
  13. INPUT may be image files or directories (directories are scanned non-recursively).
  14. """
  15. import argparse, os, sys, glob, json, zipfile, datetime
  16. from PIL import Image, ImageFilter
  17. import numpy as np
  18. Image.MAX_IMAGE_PIXELS = None
  19. EXTS = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tif', '.tiff')
  20. # ----------------------------------------------------------------------------- analysis
  21. def analyze(path):
  22. im = Image.open(path)
  23. fmt = (im.format or os.path.splitext(path)[1].lstrip('.')).upper()
  24. src_mode = im.mode
  25. is_jpeg = fmt in ('JPEG', 'JPG')
  26. declared_alpha = src_mode in ('RGBA', 'LA', 'PA') or 'transparency' in im.info
  27. rgba = im.convert('RGBA')
  28. arr = np.asarray(rgba)
  29. a = arr[..., 3]
  30. w, h = im.size
  31. px = w * h
  32. min_side = min(w, h)
  33. transp = float((a < 250).mean()) # fraction not fully opaque
  34. semi = float(((a > 10) & (a < 245)).mean()) # genuine soft/AA edges
  35. has_alpha = declared_alpha and transp > 0.01
  36. op = arr[a > 200][:, :3]
  37. n_op = max(len(op), 1)
  38. uniq = int(len(np.unique(op.reshape(-1, 3), axis=0))) if len(op) else 0
  39. flat = uniq / n_op # high => few opaque px each unique (small/jaggy)
  40. # quantized color count: collapses JPEG/AA near-duplicates so it reflects real palette
  41. if len(op):
  42. q = (op >> 3) << 3
  43. uniqq = int(len(np.unique(q.reshape(-1, 3), axis=0)))
  44. else:
  45. uniqq = 0
  46. g = rgba.convert('L')
  47. L = np.asarray(g).astype(np.float32)
  48. gx = np.abs(np.diff(L, axis=1)); gy = np.abs(np.diff(L, axis=0))
  49. edge = float((gx.mean() + gy.mean()) / 2 / 255)
  50. blur = np.asarray(g.filter(ImageFilter.GaussianBlur(1.0))).astype(np.float32)
  51. hf = float(np.abs(L - blur).mean() / 255) # high-freq energy; low+big => soft/false-res
  52. # jpeg blockiness: step magnitude on the 8px DCT grid vs everywhere
  53. if L.shape[1] > 16:
  54. d = np.abs(np.diff(L, axis=1))
  55. blk = float(d[:, 7::8].mean() / (d.mean() + 1e-6))
  56. else:
  57. blk = 1.0
  58. return dict(file=os.path.basename(path), path=path, fmt=fmt, src_mode=src_mode,
  59. w=w, h=h, px=px, min_side=min_side, is_jpeg=is_jpeg,
  60. has_alpha=has_alpha, transp=round(transp, 3), semi=round(semi, 4),
  61. uniq=uniq, uniqq=uniqq, flat=round(flat, 4), edge=round(edge, 4),
  62. hf=round(hf, 4), blk=round(blk, 2))
  63. # ----------------------------------------------------------------------------- classify
  64. def classify(f):
  65. """Return content category, primary + alternate Topaz model, and settings.
  66. Routing precedence (each rule guards the next):
  67. 1. degraded -> small AND jaggy (real low-res, needs reconstruction)
  68. 2. jpeg -> split by edge density: line-art/flat vs detailed
  69. 3. flat vector -> very low raw color count (indexed / clean vector)
  70. 4. detailed -> large and genuinely many quantized colors
  71. 5. gradient -> everything else (mid-size RGBA logos with gloss/bevel)
  72. """
  73. degraded = f['px'] < 90_000 and f['flat'] > 0.15
  74. soft_big = f['min_side'] >= 600 and f['hf'] < 0.012
  75. notes = []
  76. if degraded:
  77. category = 'Low-res / degraded'
  78. model, alt = 'Wonder 3', 'Low Resolution 2'
  79. notes.append('Genuinely small with heavy anti-aliasing and little true detail. A '
  80. 'generative model reconstructs cleaner edges than a core upscale can. '
  81. 'Try Wonder 3 first; drop to Low Resolution 2 if it over-invents detail.')
  82. elif f['is_jpeg']:
  83. if f['edge'] > 0.03:
  84. category = 'Detailed line art (JPEG)'
  85. model, alt = 'Art & CG', 'Standard'
  86. notes.append('Detailed, high-contrast linework saved as JPEG. Art & CG keeps the '
  87. 'clean stylized edges; Standard if it reads more photographic.')
  88. else:
  89. category = 'Flat logo (JPEG)'
  90. model, alt = 'Art & CG', 'Standard'
  91. notes.append('Flat-color logo softened by JPEG compression. Art & CG rebuilds the '
  92. 'clean edges.')
  93. elif f['uniq'] < 200:
  94. category = 'Flat vector logo'
  95. model, alt = 'Text & Shapes', 'Art & CG'
  96. notes.append('Few flat colors and hard edges (indexed / vector-style). Text & Shapes '
  97. 'holds edge and shape geometry; switch to Art & CG if the piece is emblem/'
  98. 'illustration-dominant rather than lettering.')
  99. elif f['px'] > 300_000 and f['uniqq'] > 700:
  100. category = 'Detailed illustration'
  101. model, alt = 'Standard', 'Art & CG'
  102. notes.append('Large, many-color illustration. Standard stays true-to-input; Art & CG '
  103. 'if you want flatter, more graphic edges.')
  104. else:
  105. category = 'Gradient / rendered logo'
  106. model, alt = 'Art & CG', 'Standard'
  107. notes.append('Smooth gradients, bevels or 3D shading. Art & CG keeps clean edges and '
  108. 'flat regions without inventing photographic texture.')
  109. # ---- settings
  110. s = {}
  111. s['fix_compression'] = 'On (Medium)' if f['is_jpeg'] else 'Off'
  112. if f['is_jpeg']:
  113. notes.append('JPEG source: enable Fix Compression in Topaz. The prepped file is a '
  114. 'lossless PNG, so no further generational loss occurs.')
  115. s['denoise'] = 'Low (1-10), only if grain remains after Fix Compression' if f['is_jpeg'] else 'Off'
  116. s['sharpen'] = 'Off (let the model handle edges)'
  117. s['face_recovery'] = 'Off (stylized skull/figure marks are not real faces)'
  118. if soft_big:
  119. s['pre_downscaling'] = 'Low-Medium'
  120. notes.append('Large but soft (high-frequency detail low for the pixel size): possible '
  121. 'false resolution. Turn on Topaz Pre-downscaling, or re-run with '
  122. '--predownscale to feed an already-concentrated file.')
  123. else:
  124. s['pre_downscaling'] = 'None'
  125. s['color_space'] = 'Preserve input (export Adobe RGB if headed to CMYK prepress)'
  126. if f['px'] < 60_000:
  127. notes.append(f"Small source ({f['w']}x{f['h']}). At Topaz's 6x ceiling that is "
  128. f"{f['w']*6}x{f['h']*6}px, so confirm the target print size fits before "
  129. "committing (see Print reach below).")
  130. return dict(category=category, model=model, alt=alt, settings=s, notes=notes,
  131. soft_big=soft_big, jaggy_small=degraded)
  132. # ----------------------------------------------------------------------------- prep ops
  133. def _box(a, size=5):
  134. """Mean filter. Uses scipy if present, else a separable numpy cumulative-sum box."""
  135. try:
  136. from scipy.ndimage import uniform_filter
  137. return uniform_filter(a, size=size, mode='nearest')
  138. except Exception:
  139. r = size // 2
  140. pad = [(r, r)] * 2 + [(0, 0)] * (a.ndim - 2)
  141. ap = np.pad(a, pad, mode='edge')
  142. c = np.cumsum(np.cumsum(ap, axis=0), axis=1)
  143. c = np.pad(c, [(1, 0), (1, 0)] + [(0, 0)] * (a.ndim - 2), mode='constant')
  144. H, W = a.shape[:2]
  145. s = (c[size:size+H, size:size+W] - c[0:H, size:size+W]
  146. - c[size:size+H, 0:W] + c[0:H, 0:W])
  147. return s / (size * size)
  148. def defringe(arr):
  149. """Edge-extend opaque colors into transparent regions so an upscale of the RGB
  150. plane doesn't pull background/halo into the logo edge. Returns RGB uint8."""
  151. rgb = arr[..., :3].astype(np.float32)
  152. a = (arr[..., 3:4].astype(np.float32)) / 255.0
  153. mask = a[..., 0] > 0.05
  154. if mask.all() or not mask.any():
  155. return arr[..., :3].copy()
  156. filled = rgb.copy()
  157. w = (a[..., 0]).copy()
  158. for _ in range(24):
  159. if w.min() > 0.5:
  160. break
  161. num = _box(filled * w[..., None], size=5)
  162. den = _box(w, size=5)[..., None] + 1e-6
  163. avg = num / den
  164. upd = w < 0.5
  165. filled[upd] = avg[upd]
  166. w = np.maximum(w, (den[..., 0] > 0.02).astype(np.float32) * 0.6)
  167. return np.clip(filled, 0, 255).astype(np.uint8)
  168. def prep_image(f, rec, outdir, do_predownscale):
  169. im = Image.open(f['path'])
  170. base = os.path.splitext(f['file'])[0]
  171. outputs, recompose = [], None
  172. rgba = im.convert('RGBA')
  173. arr = np.asarray(rgba)
  174. # optional false-resolution pre-downscale (opt-in)
  175. scaled_note = ''
  176. if do_predownscale and rec['soft_big']:
  177. nw, nh = f['w'] // 2, f['h'] // 2
  178. rgba = rgba.resize((nw, nh), Image.LANCZOS)
  179. arr = np.asarray(rgba)
  180. scaled_note = f' (pre-downscaled to {nw}x{nh} to concentrate detail)'
  181. if f['has_alpha']:
  182. # split: defringed RGB for Topaz + alpha mask for recompositing
  183. rgb = defringe(arr)
  184. rgb_path = os.path.join(outdir, f'{base}__RGB.png')
  185. Image.fromarray(rgb, 'RGB').save(rgb_path, optimize=True)
  186. outputs.append(os.path.basename(rgb_path))
  187. mask_path = os.path.join(outdir, f'{base}__ALPHA.png')
  188. Image.fromarray(arr[..., 3], 'L').save(mask_path, optimize=True)
  189. outputs.append(os.path.basename(mask_path))
  190. recompose = (f'Upscale {base}__RGB.png in Topaz. Separately upscale '
  191. f'{base}__ALPHA.png by the SAME factor (use Standard, Denoise off). '
  192. f'In Photoshop, load the upscaled alpha as a layer mask on the '
  193. f'upscaled RGB to restore clean transparency without edge halo.')
  194. else:
  195. # opaque: flatten to clean RGB (handles indexed/JPEG/grayscale), lossless PNG
  196. rgb_path = os.path.join(outdir, f'{base}__RGB.png')
  197. rgba.convert('RGB').save(rgb_path, optimize=True)
  198. outputs.append(os.path.basename(rgb_path))
  199. return outputs, recompose, scaled_note
  200. # ----------------------------------------------------------------------------- print sizing
  201. def print_sizing(w, h, max_scale=6):
  202. """Report achievable physical size at standard banner viewing-distance PPIs."""
  203. ow, oh = w * max_scale, h * max_scale
  204. rows = []
  205. for ppi, label in [(150, 'close (≤6 ft)'), (100, 'standing (≈10 ft)'), (60, 'distant (≥20 ft)')]:
  206. rows.append(f'{ow}x{oh}px @ {max_scale}x → {ow/ppi:.1f}\" x {oh/ppi:.1f}\" at {ppi} PPI ({label})')
  207. return rows
  208. # ----------------------------------------------------------------------------- report
  209. def render_report(results, predownscale):
  210. now = datetime.datetime.now().strftime('%Y-%m-%d')
  211. out = []
  212. out.append('# Topaz Pre-Pass Report\n')
  213. out.append(f'Prepped {len(results)} image(s) on {now}. Each image below lists what was '
  214. 'detected, the prepped file(s) to feed Topaz Gigapixel, and the model + settings '
  215. 'to use. Files ending `__RGB.png` go into Topaz; `__ALPHA.png` is the transparency '
  216. 'mask to recomposite after upscaling.\n')
  217. out.append('> Prep was non-destructive: color-mode cleanup, lossless re-save, and (for '
  218. 'transparent logos) alpha split with edge defringe. No sharpening or denoising was '
  219. 'applied in Python, since Topaz works best on the original signal.\n')
  220. if not predownscale:
  221. out.append('> Pre-downscale was NOT applied (run with `--predownscale` to act on false-'
  222. 'resolution flags). Where flagged, use Topaz\'s own Pre-downscaling toggle instead.\n')
  223. out.append('\n---\n')
  224. for r in results:
  225. f, rec = r['f'], r['rec']
  226. out.append(f"\n## {f['file']}\n")
  227. out.append(f"**Category:** {rec['category']} ")
  228. out.append(f"\n**Source:** {f['fmt']} {f['w']}x{f['h']} ({f['src_mode']}), "
  229. f"{'transparent' if f['has_alpha'] else 'opaque'}, "
  230. f"{f['uniq']} colors\n")
  231. out.append(f"\n**Prepped file(s):** {', '.join('`'+o+'`' for o in r['outputs'])}{r['scaled_note']}\n")
  232. out.append('\n**Topaz settings**\n')
  233. out.append(f"- Model: **{rec['model']}** (alternate: {rec['alt']})")
  234. s = rec['settings']
  235. out.append(f"- Fix Compression: {s['fix_compression']}")
  236. out.append(f"- Denoise: {s['denoise']}")
  237. out.append(f"- Sharpen: {s['sharpen']}")
  238. out.append(f"- Pre-downscaling: {s['pre_downscaling']}")
  239. out.append(f"- Face Recovery: {s['face_recovery']}")
  240. out.append(f"- Color space on export: {s['color_space']}")
  241. if r['recompose']:
  242. out.append(f"\n**Transparency:** {r['recompose']}\n")
  243. out.append('\n**Print reach** (max 6x):')
  244. for line in print_sizing(f['w'], f['h']):
  245. out.append(f'- {line}')
  246. if rec['notes']:
  247. out.append('\n**Why:**')
  248. for n in rec['notes']:
  249. out.append(f'- {n}')
  250. out.append('\n---')
  251. out.append('\n### Reading the metrics')
  252. out.append('Categories are routed from measurable features: color count and flatness '
  253. '(vector vs continuous-tone), edge density (text/shape vs photo), high-frequency '
  254. 'energy vs size (false resolution), JPEG blockiness (compression), and alpha '
  255. 'coverage (transparency handling). See `references/model-selection.md` in the skill '
  256. 'for the full routing table.')
  257. return '\n'.join(out)
  258. # ----------------------------------------------------------------------------- main
  259. def collect_inputs(paths):
  260. files = []
  261. for p in paths:
  262. if os.path.isdir(p):
  263. for e in EXTS:
  264. files += glob.glob(os.path.join(p, f'*{e}'))
  265. files += glob.glob(os.path.join(p, f'*{e.upper()}'))
  266. elif os.path.isfile(p) and p.lower().endswith(EXTS):
  267. files.append(p)
  268. return sorted(set(files))
  269. def main():
  270. ap = argparse.ArgumentParser()
  271. ap.add_argument('inputs', nargs='+')
  272. ap.add_argument('--out', required=True)
  273. ap.add_argument('--predownscale', action='store_true',
  274. help='Act on false-resolution flags by downscaling 2x before upscaling.')
  275. ap.add_argument('--zip-name', default='topaz-prepped.zip')
  276. args = ap.parse_args()
  277. files = collect_inputs(args.inputs)
  278. if not files:
  279. print('No images found.', file=sys.stderr); sys.exit(1)
  280. os.makedirs(args.out, exist_ok=True)
  281. results, analysis = [], []
  282. for path in files:
  283. f = analyze(path)
  284. rec = classify(f)
  285. outputs, recompose, scaled_note = prep_image(f, rec, args.out, args.predownscale)
  286. results.append(dict(f=f, rec=rec, outputs=outputs, recompose=recompose,
  287. scaled_note=scaled_note))
  288. analysis.append(dict(file=f['file'], category=rec['category'], model=rec['model'],
  289. alt=rec['alt'], settings=rec['settings'], outputs=outputs,
  290. metrics={k: f[k] for k in ('fmt','w','h','uniq','flat','edge','hf','blk','has_alpha','transp')}))
  291. print(f"{f['file']:34s} -> {rec['category']:24s} {rec['model']}")
  292. report = render_report(results, args.predownscale)
  293. report_path = os.path.join(args.out, 'Topaz-Prep-Report.md')
  294. with open(report_path, 'w') as fh:
  295. fh.write(report)
  296. with open(os.path.join(args.out, 'analysis.json'), 'w') as fh:
  297. json.dump(analysis, fh, indent=2)
  298. # zip prepped images + report
  299. zip_path = os.path.join(args.out, args.zip_name)
  300. with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
  301. for r in results:
  302. for o in r['outputs']:
  303. z.write(os.path.join(args.out, o), o)
  304. z.write(report_path, 'Topaz-Prep-Report.md')
  305. print(f'\nReport: {report_path}\nZip: {zip_path}')
  306. if __name__ == '__main__':
  307. main()