prep.py
Language Python15.9 KB356 lines
#!/usr/bin/env python3"""Topaz pre-pass prepper.Analyzes raster logos/images, does NON-DESTRUCTIVE prep that genuinely helps aTopaz Gigapixel upscale (mode normalization, alpha split + defringe, losslessre-save, optional false-resolution pre-downscale), and writes a per-imagerecommendation report for which Topaz model + settings to use.Design rule: Topaz wants clean ORIGINAL signal. We do not pre-sharpen orpre-denoise here -- that destroys the detail Topaz relies on and double-stackswith Topaz's own processing. Python's job is structure/format hygiene + routing.Usage:python prep.py INPUT [INPUT ...] --out OUTDIR [--predownscale] [--zip-name NAME]INPUT may be image files or directories (directories are scanned non-recursively)."""import argparse, os, sys, glob, json, zipfile, datetimefrom PIL import Image, ImageFilterimport numpy as npImage.MAX_IMAGE_PIXELS = NoneEXTS = ('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tif', '.tiff')# ----------------------------------------------------------------------------- analysisdef analyze(path):im = Image.open(path)fmt = (im.format or os.path.splitext(path)[1].lstrip('.')).upper()src_mode = im.modeis_jpeg = fmt in ('JPEG', 'JPG')declared_alpha = src_mode in ('RGBA', 'LA', 'PA') or 'transparency' in im.inforgba = im.convert('RGBA')arr = np.asarray(rgba)a = arr[..., 3]w, h = im.sizepx = w * hmin_side = min(w, h)transp = float((a < 250).mean()) # fraction not fully opaquesemi = float(((a > 10) & (a < 245)).mean()) # genuine soft/AA edgeshas_alpha = declared_alpha and transp > 0.01op = arr[a > 200][:, :3]n_op = max(len(op), 1)uniq = int(len(np.unique(op.reshape(-1, 3), axis=0))) if len(op) else 0flat = uniq / n_op # high => few opaque px each unique (small/jaggy)# quantized color count: collapses JPEG/AA near-duplicates so it reflects real paletteif len(op):q = (op >> 3) << 3uniqq = int(len(np.unique(q.reshape(-1, 3), axis=0)))else:uniqq = 0g = rgba.convert('L')L = np.asarray(g).astype(np.float32)gx = np.abs(np.diff(L, axis=1)); gy = np.abs(np.diff(L, axis=0))edge = float((gx.mean() + gy.mean()) / 2 / 255)blur = np.asarray(g.filter(ImageFilter.GaussianBlur(1.0))).astype(np.float32)hf = float(np.abs(L - blur).mean() / 255) # high-freq energy; low+big => soft/false-res# jpeg blockiness: step magnitude on the 8px DCT grid vs everywhereif L.shape[1] > 16:d = np.abs(np.diff(L, axis=1))blk = float(d[:, 7::8].mean() / (d.mean() + 1e-6))else:blk = 1.0return dict(file=os.path.basename(path), path=path, fmt=fmt, src_mode=src_mode,w=w, h=h, px=px, min_side=min_side, is_jpeg=is_jpeg,has_alpha=has_alpha, transp=round(transp, 3), semi=round(semi, 4),uniq=uniq, uniqq=uniqq, flat=round(flat, 4), edge=round(edge, 4),hf=round(hf, 4), blk=round(blk, 2))# ----------------------------------------------------------------------------- classifydef classify(f):"""Return content category, primary + alternate Topaz model, and settings.Routing precedence (each rule guards the next):1. degraded -> small AND jaggy (real low-res, needs reconstruction)2. jpeg -> split by edge density: line-art/flat vs detailed3. flat vector -> very low raw color count (indexed / clean vector)4. detailed -> large and genuinely many quantized colors5. gradient -> everything else (mid-size RGBA logos with gloss/bevel)"""degraded = f['px'] < 90_000 and f['flat'] > 0.15soft_big = f['min_side'] >= 600 and f['hf'] < 0.012notes = []if degraded:category = 'Low-res / degraded'model, alt = 'Wonder 3', 'Low Resolution 2'notes.append('Genuinely small with heavy anti-aliasing and little true detail. A ''generative model reconstructs cleaner edges than a core upscale can. ''Try Wonder 3 first; drop to Low Resolution 2 if it over-invents detail.')elif f['is_jpeg']:if f['edge'] > 0.03:category = 'Detailed line art (JPEG)'model, alt = 'Art & CG', 'Standard'notes.append('Detailed, high-contrast linework saved as JPEG. Art & CG keeps the ''clean stylized edges; Standard if it reads more photographic.')else:category = 'Flat logo (JPEG)'model, alt = 'Art & CG', 'Standard'notes.append('Flat-color logo softened by JPEG compression. Art & CG rebuilds the ''clean edges.')elif f['uniq'] < 200:category = 'Flat vector logo'model, alt = 'Text & Shapes', 'Art & CG'notes.append('Few flat colors and hard edges (indexed / vector-style). Text & Shapes ''holds edge and shape geometry; switch to Art & CG if the piece is emblem/''illustration-dominant rather than lettering.')elif f['px'] > 300_000 and f['uniqq'] > 700:category = 'Detailed illustration'model, alt = 'Standard', 'Art & CG'notes.append('Large, many-color illustration. Standard stays true-to-input; Art & CG ''if you want flatter, more graphic edges.')else:category = 'Gradient / rendered logo'model, alt = 'Art & CG', 'Standard'notes.append('Smooth gradients, bevels or 3D shading. Art & CG keeps clean edges and ''flat regions without inventing photographic texture.')# ---- settingss = {}s['fix_compression'] = 'On (Medium)' if f['is_jpeg'] else 'Off'if f['is_jpeg']:notes.append('JPEG source: enable Fix Compression in Topaz. The prepped file is a ''lossless PNG, so no further generational loss occurs.')s['denoise'] = 'Low (1-10), only if grain remains after Fix Compression' if f['is_jpeg'] else 'Off's['sharpen'] = 'Off (let the model handle edges)'s['face_recovery'] = 'Off (stylized skull/figure marks are not real faces)'if soft_big:s['pre_downscaling'] = 'Low-Medium'notes.append('Large but soft (high-frequency detail low for the pixel size): possible ''false resolution. Turn on Topaz Pre-downscaling, or re-run with ''--predownscale to feed an already-concentrated file.')else:s['pre_downscaling'] = 'None's['color_space'] = 'Preserve input (export Adobe RGB if headed to CMYK prepress)'if f['px'] < 60_000:notes.append(f"Small source ({f['w']}x{f['h']}). At Topaz's 6x ceiling that is "f"{f['w']*6}x{f['h']*6}px, so confirm the target print size fits before ""committing (see Print reach below).")return dict(category=category, model=model, alt=alt, settings=s, notes=notes,soft_big=soft_big, jaggy_small=degraded)# ----------------------------------------------------------------------------- prep opsdef _box(a, size=5):"""Mean filter. Uses scipy if present, else a separable numpy cumulative-sum box."""try:from scipy.ndimage import uniform_filterreturn uniform_filter(a, size=size, mode='nearest')except Exception:r = size // 2pad = [(r, r)] * 2 + [(0, 0)] * (a.ndim - 2)ap = np.pad(a, pad, mode='edge')c = np.cumsum(np.cumsum(ap, axis=0), axis=1)c = np.pad(c, [(1, 0), (1, 0)] + [(0, 0)] * (a.ndim - 2), mode='constant')H, W = a.shape[:2]s = (c[size:size+H, size:size+W] - c[0:H, size:size+W]- c[size:size+H, 0:W] + c[0:H, 0:W])return s / (size * size)def defringe(arr):"""Edge-extend opaque colors into transparent regions so an upscale of the RGBplane doesn't pull background/halo into the logo edge. Returns RGB uint8."""rgb = arr[..., :3].astype(np.float32)a = (arr[..., 3:4].astype(np.float32)) / 255.0mask = a[..., 0] > 0.05if mask.all() or not mask.any():return arr[..., :3].copy()filled = rgb.copy()w = (a[..., 0]).copy()for _ in range(24):if w.min() > 0.5:breaknum = _box(filled * w[..., None], size=5)den = _box(w, size=5)[..., None] + 1e-6avg = num / denupd = w < 0.5filled[upd] = avg[upd]w = np.maximum(w, (den[..., 0] > 0.02).astype(np.float32) * 0.6)return np.clip(filled, 0, 255).astype(np.uint8)def prep_image(f, rec, outdir, do_predownscale):im = Image.open(f['path'])base = os.path.splitext(f['file'])[0]outputs, recompose = [], Nonergba = im.convert('RGBA')arr = np.asarray(rgba)# optional false-resolution pre-downscale (opt-in)scaled_note = ''if do_predownscale and rec['soft_big']:nw, nh = f['w'] // 2, f['h'] // 2rgba = rgba.resize((nw, nh), Image.LANCZOS)arr = np.asarray(rgba)scaled_note = f' (pre-downscaled to {nw}x{nh} to concentrate detail)'if f['has_alpha']:# split: defringed RGB for Topaz + alpha mask for recompositingrgb = defringe(arr)rgb_path = os.path.join(outdir, f'{base}__RGB.png')Image.fromarray(rgb, 'RGB').save(rgb_path, optimize=True)outputs.append(os.path.basename(rgb_path))mask_path = os.path.join(outdir, f'{base}__ALPHA.png')Image.fromarray(arr[..., 3], 'L').save(mask_path, optimize=True)outputs.append(os.path.basename(mask_path))recompose = (f'Upscale {base}__RGB.png in Topaz. Separately upscale 'f'{base}__ALPHA.png by the SAME factor (use Standard, Denoise off). 'f'In Photoshop, load the upscaled alpha as a layer mask on the 'f'upscaled RGB to restore clean transparency without edge halo.')else:# opaque: flatten to clean RGB (handles indexed/JPEG/grayscale), lossless PNGrgb_path = os.path.join(outdir, f'{base}__RGB.png')rgba.convert('RGB').save(rgb_path, optimize=True)outputs.append(os.path.basename(rgb_path))return outputs, recompose, scaled_note# ----------------------------------------------------------------------------- print sizingdef print_sizing(w, h, max_scale=6):"""Report achievable physical size at standard banner viewing-distance PPIs."""ow, oh = w * max_scale, h * max_scalerows = []for ppi, label in [(150, 'close (≤6 ft)'), (100, 'standing (≈10 ft)'), (60, 'distant (≥20 ft)')]:rows.append(f'{ow}x{oh}px @ {max_scale}x → {ow/ppi:.1f}\" x {oh/ppi:.1f}\" at {ppi} PPI ({label})')return rows# ----------------------------------------------------------------------------- reportdef render_report(results, predownscale):now = datetime.datetime.now().strftime('%Y-%m-%d')out = []out.append('# Topaz Pre-Pass Report\n')out.append(f'Prepped {len(results)} image(s) on {now}. Each image below lists what was ''detected, the prepped file(s) to feed Topaz Gigapixel, and the model + settings ''to use. Files ending `__RGB.png` go into Topaz; `__ALPHA.png` is the transparency ''mask to recomposite after upscaling.\n')out.append('> Prep was non-destructive: color-mode cleanup, lossless re-save, and (for ''transparent logos) alpha split with edge defringe. No sharpening or denoising was ''applied in Python, since Topaz works best on the original signal.\n')if not predownscale:out.append('> Pre-downscale was NOT applied (run with `--predownscale` to act on false-''resolution flags). Where flagged, use Topaz\'s own Pre-downscaling toggle instead.\n')out.append('\n---\n')for r in results:f, rec = r['f'], r['rec']out.append(f"\n## {f['file']}\n")out.append(f"**Category:** {rec['category']} ")out.append(f"\n**Source:** {f['fmt']} {f['w']}x{f['h']} ({f['src_mode']}), "f"{'transparent' if f['has_alpha'] else 'opaque'}, "f"{f['uniq']} colors\n")out.append(f"\n**Prepped file(s):** {', '.join('`'+o+'`' for o in r['outputs'])}{r['scaled_note']}\n")out.append('\n**Topaz settings**\n')out.append(f"- Model: **{rec['model']}** (alternate: {rec['alt']})")s = rec['settings']out.append(f"- Fix Compression: {s['fix_compression']}")out.append(f"- Denoise: {s['denoise']}")out.append(f"- Sharpen: {s['sharpen']}")out.append(f"- Pre-downscaling: {s['pre_downscaling']}")out.append(f"- Face Recovery: {s['face_recovery']}")out.append(f"- Color space on export: {s['color_space']}")if r['recompose']:out.append(f"\n**Transparency:** {r['recompose']}\n")out.append('\n**Print reach** (max 6x):')for line in print_sizing(f['w'], f['h']):out.append(f'- {line}')if rec['notes']:out.append('\n**Why:**')for n in rec['notes']:out.append(f'- {n}')out.append('\n---')out.append('\n### Reading the metrics')out.append('Categories are routed from measurable features: color count and flatness ''(vector vs continuous-tone), edge density (text/shape vs photo), high-frequency ''energy vs size (false resolution), JPEG blockiness (compression), and alpha ''coverage (transparency handling). See `references/model-selection.md` in the skill ''for the full routing table.')return '\n'.join(out)# ----------------------------------------------------------------------------- maindef collect_inputs(paths):files = []for p in paths:if os.path.isdir(p):for e in EXTS:files += glob.glob(os.path.join(p, f'*{e}'))files += glob.glob(os.path.join(p, f'*{e.upper()}'))elif os.path.isfile(p) and p.lower().endswith(EXTS):files.append(p)return sorted(set(files))def main():ap = argparse.ArgumentParser()ap.add_argument('inputs', nargs='+')ap.add_argument('--out', required=True)ap.add_argument('--predownscale', action='store_true',help='Act on false-resolution flags by downscaling 2x before upscaling.')ap.add_argument('--zip-name', default='topaz-prepped.zip')args = ap.parse_args()files = collect_inputs(args.inputs)if not files:print('No images found.', file=sys.stderr); sys.exit(1)os.makedirs(args.out, exist_ok=True)results, analysis = [], []for path in files:f = analyze(path)rec = classify(f)outputs, recompose, scaled_note = prep_image(f, rec, args.out, args.predownscale)results.append(dict(f=f, rec=rec, outputs=outputs, recompose=recompose,scaled_note=scaled_note))analysis.append(dict(file=f['file'], category=rec['category'], model=rec['model'],alt=rec['alt'], settings=rec['settings'], outputs=outputs,metrics={k: f[k] for k in ('fmt','w','h','uniq','flat','edge','hf','blk','has_alpha','transp')}))print(f"{f['file']:34s} -> {rec['category']:24s} {rec['model']}")report = render_report(results, args.predownscale)report_path = os.path.join(args.out, 'Topaz-Prep-Report.md')with open(report_path, 'w') as fh:fh.write(report)with open(os.path.join(args.out, 'analysis.json'), 'w') as fh:json.dump(analysis, fh, indent=2)# zip prepped images + reportzip_path = os.path.join(args.out, args.zip_name)with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:for r in results:for o in r['outputs']:z.write(os.path.join(args.out, o), o)z.write(report_path, 'Topaz-Prep-Report.md')print(f'\nReport: {report_path}\nZip: {zip_path}')if __name__ == '__main__':main()