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

enhance_logo.py

Language Python8.1 KB203 lines

skills/lowres-logo-rescue/scripts/enhance_logo.py

  1. #!/usr/bin/env python3
  2. """
  3. Low-res logo rescue pipeline (no neural models required).
  4. For soft, low-resolution *flat* logos this classic pipeline beats neural
  5. upscalers: it denoises, upscales cleanly (Lanczos), sharpens, and can "flatten"
  6. the art back to a small set of solid colors so fuzzy anti-aliased edges snap
  7. crisp again. That is both more readable AND a far better input for vector tracing
  8. (Illustrator Image Trace / Vector Magic / potrace) than the original blur.
  9. It does NOT invent detail. If the source is a photo/scan of a physical object
  10. (a medallion, an embroidered patch, a sign), no amount of upscaling recovers
  11. edges the camera never captured -- the script will say so in its readout.
  12. Produces, per image:
  13. <stem>_A_enhanced.png denoise + Lanczos upscale + unsharp + gentle contrast.
  14. Natural look, no posterization. Safe general upscale.
  15. <stem>_B_flat<N>.png enhanced, then k-means color-flatten to N solid colors.
  16. Crisp edges, tiny palette. Best "make it readable"
  17. version and the file to hand a tracer. (Flat art only.)
  18. <stem>_C_traceready.png high-contrast, flattened, on solid white -- optimized
  19. purely as a bitmap-trace source.
  20. Usage:
  21. python3 enhance_logo.py IMAGE [IMAGE ...] [--outdir DIR]
  22. [--scale N] [--long-edge PX] [--colors N]
  23. python3 enhance_logo.py /path/to/folder # processes images in folder
  24. Examples:
  25. python3 enhance_logo.py "client_logo.png"
  26. python3 enhance_logo.py *.png --outdir ./enhanced --colors 8
  27. """
  28. import sys, os, glob, argparse
  29. import numpy as np
  30. import cv2
  31. from PIL import Image
  32. from sklearn.cluster import KMeans
  33. IMG_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp")
  34. def load_rgba(path):
  35. return np.array(Image.open(path).convert("RGBA"))
  36. def analyze(rgb):
  37. """Decide flat-art vs photographic, robustly.
  38. Two signals, because a naive unique-color count is fooled by photos that
  39. have large flat-ish regions (e.g. a photo of a blue medallion):
  40. * color concentration -- what fraction of pixels the 8 most common colors
  41. cover. Flat logos are dominated by a few colors (high); photos spread
  42. across many (low).
  43. * soft-transition share -- fraction of pixels sitting on gentle gradients
  44. (mid gradient magnitude). Photos are full of soft shading; flat art
  45. transitions are either flat or a hard edge.
  46. Returns (is_photographic, top8_coverage, unique_colors, soft_share)."""
  47. small = cv2.resize(rgb, (256, 256), interpolation=cv2.INTER_AREA)
  48. q = (small // 8).reshape(-1, 3)
  49. vals, counts = np.unique(q, axis=0, return_counts=True)
  50. order = np.argsort(-counts)
  51. top8 = float(counts[order[:8]].sum() / counts.sum())
  52. g = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY)
  53. mag = np.sqrt(cv2.Sobel(g, cv2.CV_32F, 1, 0) ** 2 +
  54. cv2.Sobel(g, cv2.CV_32F, 0, 1) ** 2)
  55. soft = float(((mag > 10) & (mag < 60)).mean())
  56. photographic = top8 < 0.65 or (top8 < 0.82 and soft > 0.18)
  57. return photographic, top8, len(vals), soft
  58. def palette_size(top8):
  59. """Fewer, cleaner colors when the art is already highly concentrated."""
  60. if top8 > 0.90:
  61. return 6
  62. if top8 > 0.80:
  63. return 8
  64. return 10
  65. def scale_for(w, h, max_scale, long_edge):
  66. return max(1.0, min(max_scale, long_edge / max(w, h)))
  67. def denoise(rgb, strength):
  68. return cv2.bilateralFilter(rgb, d=7, sigmaColor=strength, sigmaSpace=7)
  69. def upscale(a, scale):
  70. h, w = a.shape[:2]
  71. return cv2.resize(a, (int(round(w * scale)), int(round(h * scale))),
  72. interpolation=cv2.INTER_LANCZOS4)
  73. def unsharp(rgb, amount=1.0, radius=2.0):
  74. blur = cv2.GaussianBlur(rgb, (0, 0), radius)
  75. out = cv2.addWeighted(rgb, 1 + amount, blur, -amount, 0)
  76. return np.clip(out, 0, 255).astype(np.uint8)
  77. def gentle_contrast(rgb):
  78. lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)
  79. l, a, b = cv2.split(lab)
  80. l = cv2.createCLAHE(clipLimit=1.6, tileGridSize=(8, 8)).apply(l)
  81. return cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2RGB)
  82. def kmeans_flatten(rgb, n_colors, alpha=None):
  83. """Snap to n solid colors. If alpha given, cluster only opaque pixels."""
  84. h, w = rgb.shape[:2]
  85. flat = rgb.reshape(-1, 3).astype(np.float32)
  86. sample = flat[alpha.reshape(-1) > 20] if alpha is not None else flat
  87. if len(sample) > 60000:
  88. sample = sample[np.random.choice(len(sample), 60000, replace=False)]
  89. km = KMeans(n_clusters=min(n_colors, len(np.unique(sample, axis=0))),
  90. n_init=4, random_state=0).fit(sample)
  91. out = km.cluster_centers_[km.predict(flat)].reshape(h, w, 3)
  92. return np.clip(out, 0, 255).astype(np.uint8)
  93. def save(rgb, alpha, path):
  94. if alpha is not None:
  95. Image.fromarray(np.dstack([rgb, alpha]), "RGBA").save(path)
  96. else:
  97. Image.fromarray(rgb, "RGB").save(path)
  98. print(" wrote", os.path.basename(path))
  99. def process(path, outdir, max_scale, long_edge, forced_colors):
  100. stem = os.path.splitext(os.path.basename(path))[0].split("-")[0].strip().rstrip("_")
  101. rgba = load_rgba(path)
  102. rgb, alpha = rgba[:, :, :3], rgba[:, :, 3]
  103. has_alpha = alpha.min() < 250
  104. h, w = rgb.shape[:2]
  105. photographic, top8, nuniq, soft = analyze(rgb)
  106. scale = scale_for(w, h, max_scale, long_edge)
  107. kind = "photographic (limited gains, do NOT vector-trace)" if photographic \
  108. else "flat art (good vectorize candidate)"
  109. print(f"\n{os.path.basename(path)}: {w}x{h} -> {kind}"
  110. f" [top8={top8:.2f} soft={soft:.2f} uniq={nuniq}]"
  111. f" upscale x{scale:.2f} alpha={has_alpha}")
  112. d = denoise(rgb, strength=45 if photographic else 30)
  113. up = gentle_contrast(upscale(d, scale))
  114. a_up = upscale(alpha, scale) if has_alpha else None
  115. A = unsharp(up, amount=1.0, radius=2.0)
  116. save(A, a_up, os.path.join(outdir, f"{stem}_A_enhanced.png"))
  117. n_colors = forced_colors or (18 if photographic else palette_size(top8))
  118. B = cv2.medianBlur(kmeans_flatten(A, n_colors, alpha=a_up), 3)
  119. save(B, a_up, os.path.join(outdir, f"{stem}_B_flat{n_colors}.png"))
  120. if a_up is not None:
  121. af = (a_up.astype(np.float32) / 255.0)[:, :, None]
  122. C = (B * af + np.full_like(B, 255) * (1 - af)).astype(np.uint8)
  123. else:
  124. C = B.copy()
  125. save(unsharp(C, amount=0.6, radius=1.5), None,
  126. os.path.join(outdir, f"{stem}_C_traceready.png"))
  127. return dict(name=os.path.basename(path), photographic=photographic,
  128. out=f"{A.shape[1]}x{A.shape[0]}", colors=n_colors)
  129. def collect(paths):
  130. out = []
  131. for p in paths:
  132. if os.path.isdir(p):
  133. out += [f for f in sorted(glob.glob(os.path.join(p, "*")))
  134. if f.lower().endswith(IMG_EXTS)]
  135. else:
  136. out.append(p)
  137. return out
  138. def main():
  139. ap = argparse.ArgumentParser(description="Rescue low-res logos for readability + tracing.")
  140. ap.add_argument("paths", nargs="+", help="image files, globs, or a folder")
  141. ap.add_argument("--outdir", default=None, help="output dir (default: alongside input)")
  142. ap.add_argument("--scale", type=float, default=3.0, help="max upscale factor (default 3)")
  143. ap.add_argument("--long-edge", type=int, default=3600, help="cap on upscaled long edge px")
  144. ap.add_argument("--colors", type=int, default=None, help="force flatten palette size")
  145. args = ap.parse_args()
  146. files = collect(args.paths)
  147. if not files:
  148. print("No images found."); sys.exit(1)
  149. results = []
  150. for f in files:
  151. outdir = args.outdir or os.path.dirname(os.path.abspath(f))
  152. os.makedirs(outdir, exist_ok=True)
  153. results.append(process(f, outdir, args.scale, args.long_edge, args.colors))
  154. print("\nSUMMARY")
  155. for r in results:
  156. print(f" {r['name']}: {'PHOTO' if r['photographic'] else 'flat'}, "
  157. f"out {r['out']}, flatten palette {r['colors']}")
  158. if any(r["photographic"] for r in results):
  159. print("\nNote: photographic sources can't be sharpened into vector-quality art. "
  160. "Chase the original/official vector or rebuild it instead of tracing.")
  161. if __name__ == "__main__":
  162. main()