enhance_logo.py
Language Python8.1 KB203 lines
#!/usr/bin/env python3"""Low-res logo rescue pipeline (no neural models required).For soft, low-resolution *flat* logos this classic pipeline beats neuralupscalers: it denoises, upscales cleanly (Lanczos), sharpens, and can "flatten"the art back to a small set of solid colors so fuzzy anti-aliased edges snapcrisp again. That is both more readable AND a far better input for vector tracing(Illustrator Image Trace / Vector Magic / potrace) than the original blur.It does NOT invent detail. If the source is a photo/scan of a physical object(a medallion, an embroidered patch, a sign), no amount of upscaling recoversedges the camera never captured -- the script will say so in its readout.Produces, per image:<stem>_A_enhanced.png denoise + Lanczos upscale + unsharp + gentle contrast.Natural look, no posterization. Safe general upscale.<stem>_B_flat<N>.png enhanced, then k-means color-flatten to N solid colors.Crisp edges, tiny palette. Best "make it readable"version and the file to hand a tracer. (Flat art only.)<stem>_C_traceready.png high-contrast, flattened, on solid white -- optimizedpurely as a bitmap-trace source.Usage:python3 enhance_logo.py IMAGE [IMAGE ...] [--outdir DIR][--scale N] [--long-edge PX] [--colors N]python3 enhance_logo.py /path/to/folder # processes images in folderExamples:python3 enhance_logo.py "client_logo.png"python3 enhance_logo.py *.png --outdir ./enhanced --colors 8"""import sys, os, glob, argparseimport numpy as npimport cv2from PIL import Imagefrom sklearn.cluster import KMeansIMG_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp")def load_rgba(path):return np.array(Image.open(path).convert("RGBA"))def analyze(rgb):"""Decide flat-art vs photographic, robustly.Two signals, because a naive unique-color count is fooled by photos thathave large flat-ish regions (e.g. a photo of a blue medallion):* color concentration -- what fraction of pixels the 8 most common colorscover. Flat logos are dominated by a few colors (high); photos spreadacross many (low).* soft-transition share -- fraction of pixels sitting on gentle gradients(mid gradient magnitude). Photos are full of soft shading; flat arttransitions are either flat or a hard edge.Returns (is_photographic, top8_coverage, unique_colors, soft_share)."""small = cv2.resize(rgb, (256, 256), interpolation=cv2.INTER_AREA)q = (small // 8).reshape(-1, 3)vals, counts = np.unique(q, axis=0, return_counts=True)order = np.argsort(-counts)top8 = float(counts[order[:8]].sum() / counts.sum())g = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY)mag = np.sqrt(cv2.Sobel(g, cv2.CV_32F, 1, 0) ** 2 +cv2.Sobel(g, cv2.CV_32F, 0, 1) ** 2)soft = float(((mag > 10) & (mag < 60)).mean())photographic = top8 < 0.65 or (top8 < 0.82 and soft > 0.18)return photographic, top8, len(vals), softdef palette_size(top8):"""Fewer, cleaner colors when the art is already highly concentrated."""if top8 > 0.90:return 6if top8 > 0.80:return 8return 10def scale_for(w, h, max_scale, long_edge):return max(1.0, min(max_scale, long_edge / max(w, h)))def denoise(rgb, strength):return cv2.bilateralFilter(rgb, d=7, sigmaColor=strength, sigmaSpace=7)def upscale(a, scale):h, w = a.shape[:2]return cv2.resize(a, (int(round(w * scale)), int(round(h * scale))),interpolation=cv2.INTER_LANCZOS4)def unsharp(rgb, amount=1.0, radius=2.0):blur = cv2.GaussianBlur(rgb, (0, 0), radius)out = cv2.addWeighted(rgb, 1 + amount, blur, -amount, 0)return np.clip(out, 0, 255).astype(np.uint8)def gentle_contrast(rgb):lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)l, a, b = cv2.split(lab)l = cv2.createCLAHE(clipLimit=1.6, tileGridSize=(8, 8)).apply(l)return cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2RGB)def kmeans_flatten(rgb, n_colors, alpha=None):"""Snap to n solid colors. If alpha given, cluster only opaque pixels."""h, w = rgb.shape[:2]flat = rgb.reshape(-1, 3).astype(np.float32)sample = flat[alpha.reshape(-1) > 20] if alpha is not None else flatif len(sample) > 60000:sample = sample[np.random.choice(len(sample), 60000, replace=False)]km = KMeans(n_clusters=min(n_colors, len(np.unique(sample, axis=0))),n_init=4, random_state=0).fit(sample)out = km.cluster_centers_[km.predict(flat)].reshape(h, w, 3)return np.clip(out, 0, 255).astype(np.uint8)def save(rgb, alpha, path):if alpha is not None:Image.fromarray(np.dstack([rgb, alpha]), "RGBA").save(path)else:Image.fromarray(rgb, "RGB").save(path)print(" wrote", os.path.basename(path))def process(path, outdir, max_scale, long_edge, forced_colors):stem = os.path.splitext(os.path.basename(path))[0].split("-")[0].strip().rstrip("_")rgba = load_rgba(path)rgb, alpha = rgba[:, :, :3], rgba[:, :, 3]has_alpha = alpha.min() < 250h, w = rgb.shape[:2]photographic, top8, nuniq, soft = analyze(rgb)scale = scale_for(w, h, max_scale, long_edge)kind = "photographic (limited gains, do NOT vector-trace)" if photographic \else "flat art (good vectorize candidate)"print(f"\n{os.path.basename(path)}: {w}x{h} -> {kind}"f" [top8={top8:.2f} soft={soft:.2f} uniq={nuniq}]"f" upscale x{scale:.2f} alpha={has_alpha}")d = denoise(rgb, strength=45 if photographic else 30)up = gentle_contrast(upscale(d, scale))a_up = upscale(alpha, scale) if has_alpha else NoneA = unsharp(up, amount=1.0, radius=2.0)save(A, a_up, os.path.join(outdir, f"{stem}_A_enhanced.png"))n_colors = forced_colors or (18 if photographic else palette_size(top8))B = cv2.medianBlur(kmeans_flatten(A, n_colors, alpha=a_up), 3)save(B, a_up, os.path.join(outdir, f"{stem}_B_flat{n_colors}.png"))if a_up is not None:af = (a_up.astype(np.float32) / 255.0)[:, :, None]C = (B * af + np.full_like(B, 255) * (1 - af)).astype(np.uint8)else:C = B.copy()save(unsharp(C, amount=0.6, radius=1.5), None,os.path.join(outdir, f"{stem}_C_traceready.png"))return dict(name=os.path.basename(path), photographic=photographic,out=f"{A.shape[1]}x{A.shape[0]}", colors=n_colors)def collect(paths):out = []for p in paths:if os.path.isdir(p):out += [f for f in sorted(glob.glob(os.path.join(p, "*")))if f.lower().endswith(IMG_EXTS)]else:out.append(p)return outdef main():ap = argparse.ArgumentParser(description="Rescue low-res logos for readability + tracing.")ap.add_argument("paths", nargs="+", help="image files, globs, or a folder")ap.add_argument("--outdir", default=None, help="output dir (default: alongside input)")ap.add_argument("--scale", type=float, default=3.0, help="max upscale factor (default 3)")ap.add_argument("--long-edge", type=int, default=3600, help="cap on upscaled long edge px")ap.add_argument("--colors", type=int, default=None, help="force flatten palette size")args = ap.parse_args()files = collect(args.paths)if not files:print("No images found."); sys.exit(1)results = []for f in files:outdir = args.outdir or os.path.dirname(os.path.abspath(f))os.makedirs(outdir, exist_ok=True)results.append(process(f, outdir, args.scale, args.long_edge, args.colors))print("\nSUMMARY")for r in results:print(f" {r['name']}: {'PHOTO' if r['photographic'] else 'flat'}, "f"out {r['out']}, flatten palette {r['colors']}")if any(r["photographic"] for r in results):print("\nNote: photographic sources can't be sharpened into vector-quality art. ""Chase the original/official vector or rebuild it instead of tracing.")if __name__ == "__main__":main()