|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Build the app icon (.icns) from SPEED2.EXE's own icon resource. |
| 3 | +
|
| 4 | +The exe carries one icon group, IDI_ICON1, with a single 32x32 24-bit image |
| 5 | +(the grey NFS logo; the NFSU_icon.ico next to it is the same picture). The |
| 6 | +logo is white on grey, every other shade is anti-aliasing, so it is traced |
| 7 | +back to vectors: the edge is taken where a smooth enlargement of the |
| 8 | +coverage crosses 50 %, fitted with straight lines, and the lines are snapped |
| 9 | +to the logo's own angles (level and its italic slant) so the corners come |
| 10 | +out sharp. Other icons are enlarged in nearest-neighbour steps. The result |
| 11 | +is cut to the macOS icon shape (an 824 px rounded tile on the 1024 grid, |
| 12 | +with a soft shadow) and written in every size iconutil wants. For personal |
| 13 | +use: the artwork is EA's. |
| 14 | +
|
| 15 | +Usage: tools/exeicon.py <SPEED2.EXE> <out.icns> [<preview.png>] |
| 16 | +""" |
| 17 | +import io, math, os, struct, subprocess, sys, tempfile |
| 18 | +import pefile |
| 19 | +from PIL import Image, ImageChops, ImageDraw, ImageFilter |
| 20 | + |
| 21 | +RT_ICON, RT_GROUP_ICON = 3, 14 |
| 22 | +CANVAS, TILE, RADIUS = 1024, 824, 185 # Big Sur+ icon grid |
| 23 | + |
| 24 | + |
| 25 | +def exe_icons(path): |
| 26 | + """All images of the exe's first icon group, as (w, h, bpp, bytes).""" |
| 27 | + pe = pefile.PE(path, fast_load=True) |
| 28 | + pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_RESOURCE']]) |
| 29 | + icons, group = {}, None |
| 30 | + for t in pe.DIRECTORY_ENTRY_RESOURCE.entries: |
| 31 | + for e in t.directory.entries: |
| 32 | + for lang in e.directory.entries: |
| 33 | + d = lang.data.struct |
| 34 | + raw = pe.get_data(d.OffsetToData, d.Size) |
| 35 | + if t.id == RT_ICON: |
| 36 | + icons[e.id] = raw |
| 37 | + elif t.id == RT_GROUP_ICON and group is None: |
| 38 | + group = raw |
| 39 | + if group is None: |
| 40 | + sys.exit("exeicon: no icon group in %s" % path) |
| 41 | + _, _, count = struct.unpack_from('<HHH', group) |
| 42 | + out = [] |
| 43 | + for i in range(count): |
| 44 | + w, h, _, _, _, bpp, size, iid = struct.unpack_from('<BBBBHHIH', group, 6 + 14 * i) |
| 45 | + if iid in icons: |
| 46 | + out.append((w or 256, h or 256, bpp, icons[iid])) |
| 47 | + return out |
| 48 | + |
| 49 | + |
| 50 | +def decode(w, h, bpp, raw): |
| 51 | + """One RT_ICON image (PNG or BMP DIB with AND mask) -> RGBA.""" |
| 52 | + if raw[:8] == b'\x89PNG\r\n\x1a\n': |
| 53 | + return Image.open(io.BytesIO(raw)).convert('RGBA') |
| 54 | + # Wrap the DIB in a one-image .ico and let Pillow apply the AND mask. |
| 55 | + ico = struct.pack('<HHH', 0, 1, 1) + struct.pack('<BBBBHHII', w & 255, h & 255, 0, 0, 1, bpp, |
| 56 | + len(raw), 22) + raw |
| 57 | + im = Image.open(io.BytesIO(ico)) |
| 58 | + im.size = (w, h) |
| 59 | + return im.convert('RGBA') |
| 60 | + |
| 61 | + |
| 62 | +def pixels(im): |
| 63 | + p = im.convert('RGB').load() |
| 64 | + return [p[x, y] for y in range(im.height) for x in range(im.width)] |
| 65 | + |
| 66 | + |
| 67 | +def two_tone(art): |
| 68 | + """(background, foreground) if art is one colour on another plus blends of the two.""" |
| 69 | + px = pixels(art) |
| 70 | + bg = max(set(px), key=px.count) |
| 71 | + fg = max(px, key=lambda c: sum((a - b) ** 2 for a, b in zip(c, bg))) |
| 72 | + span = sum((a - b) ** 2 for a, b in zip(fg, bg)) |
| 73 | + if span < 64 ** 2: |
| 74 | + return None |
| 75 | + for c in set(px): |
| 76 | + t = sum((a - b) * (f - b) for a, b, f in zip(c, bg, fg)) / span |
| 77 | + if sum((a - (b + t * (f - b))) ** 2 for a, b, f in zip(c, bg, fg)) > 12 ** 2: |
| 78 | + return None |
| 79 | + return bg, fg |
| 80 | + |
| 81 | + |
| 82 | +def contours(f, w, h): |
| 83 | + """Marching squares at 0.5 on f[y][x] (0 outside): closed loops of points.""" |
| 84 | + def v(x, y): |
| 85 | + return f[y][x] if 0 <= x < w and 0 <= y < h else 0.0 |
| 86 | + |
| 87 | + def pt(e): |
| 88 | + k, x, y = e |
| 89 | + a, b = (v(x, y), v(x + 1, y)) if k == 'h' else (v(x, y), v(x, y + 1)) |
| 90 | + t = (0.5 - a) / (b - a) |
| 91 | + return (x + t, y) if k == 'h' else (x, y + t) |
| 92 | + adj = {} |
| 93 | + |
| 94 | + def link(p, q): |
| 95 | + adj.setdefault(p, []).append(q) |
| 96 | + adj.setdefault(q, []).append(p) |
| 97 | + for y in range(-1, h): |
| 98 | + for x in range(-1, w): |
| 99 | + a, b, c, d = v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1) |
| 100 | + T, R, B, L = ('h', x, y), ('v', x + 1, y), ('h', x, y + 1), ('v', x, y) |
| 101 | + s = (a >= .5) | (b >= .5) << 1 | (c >= .5) << 2 | (d >= .5) << 3 |
| 102 | + if s in (0, 15): |
| 103 | + continue |
| 104 | + if s in (5, 10): # saddle: the centre decides |
| 105 | + if (s == 5) == ((a + b + c + d) / 4 >= .5): |
| 106 | + link(T, R); link(B, L) |
| 107 | + else: |
| 108 | + link(T, L); link(B, R) |
| 109 | + continue |
| 110 | + es = [e for e, p, q in ((T, a, b), (R, b, c), (B, d, c), (L, a, d)) if (p >= .5) != (q >= .5)] |
| 111 | + link(es[0], es[1]) |
| 112 | + loops, seen = [], set() |
| 113 | + for e in adj: |
| 114 | + loop, prev = [], None |
| 115 | + while e not in seen: |
| 116 | + seen.add(e) |
| 117 | + loop.append(pt(e)) |
| 118 | + nxt = [q for q in adj[e] if q != prev and q not in seen] or [q for q in adj[e] if q != prev] |
| 119 | + prev, e = e, nxt[0] |
| 120 | + if loop: |
| 121 | + loops.append(loop) |
| 122 | + return loops |
| 123 | + |
| 124 | + |
| 125 | +def corners(pts, a, b, tol, out): |
| 126 | + """Douglas-Peucker: indices in pts[a:b] where the outline turns.""" |
| 127 | + (x0, y0), (x1, y1) = pts[a], pts[b] |
| 128 | + dx, dy = x1 - x0, y1 - y0 |
| 129 | + n = math.hypot(dx, dy) or 1e-9 |
| 130 | + i, dm = a, -1.0 |
| 131 | + for k in range(a + 1, b): |
| 132 | + d = abs(dy * (pts[k][0] - x0) - dx * (pts[k][1] - y0)) / n |
| 133 | + if d > dm: |
| 134 | + i, dm = k, d |
| 135 | + if dm > tol: |
| 136 | + corners(pts, a, i, tol, out) |
| 137 | + corners(pts, i, b, tol, out) |
| 138 | + else: |
| 139 | + out.append(a) |
| 140 | + |
| 141 | + |
| 142 | +def fit(pts): |
| 143 | + """Least-squares line through pts: [centre, angle, length].""" |
| 144 | + n = len(pts) |
| 145 | + cx, cy = sum(p[0] for p in pts) / n, sum(p[1] for p in pts) / n |
| 146 | + sxx = sum((p[0] - cx) ** 2 for p in pts) |
| 147 | + syy = sum((p[1] - cy) ** 2 for p in pts) |
| 148 | + sxy = sum((p[0] - cx) * (p[1] - cy) for p in pts) |
| 149 | + return [(cx, cy), 0.5 * math.atan2(2 * sxy, sxx - syy), math.dist(pts[0], pts[-1])] |
| 150 | + |
| 151 | + |
| 152 | +def meet(l1, l2): |
| 153 | + (x1, y1), a1, _ = l1 |
| 154 | + (x2, y2), a2, _ = l2 |
| 155 | + den = math.cos(a1) * math.sin(a2) - math.sin(a1) * math.cos(a2) |
| 156 | + if abs(den) < 0.05: |
| 157 | + return None |
| 158 | + t = ((x2 - x1) * math.sin(a2) - (y2 - y1) * math.cos(a2)) / den |
| 159 | + return (x1 + t * math.cos(a1), y1 + t * math.sin(a1)) |
| 160 | + |
| 161 | + |
| 162 | +def trace(art, bg, fg, up=8, tol=0.25, snap=10): |
| 163 | + """The foreground of a two-tone image as polygons, in source pixels.""" |
| 164 | + w, h = art.size |
| 165 | + lum = lambda c: sum(c[:3]) |
| 166 | + lb, lf = lum(bg), lum(fg) |
| 167 | + cov = Image.new('L', art.size) |
| 168 | + cov.putdata([max(0, min(255, round((lum(c) - lb) * 255 / (lf - lb)))) for c in pixels(art)]) |
| 169 | + px = cov.resize((w * up, h * up), Image.BICUBIC).load() |
| 170 | + f = [[px[x, y] / 255 for x in range(w * up)] for y in range(h * up)] |
| 171 | + outlines = [] |
| 172 | + for loop in contours(f, w * up, h * up): |
| 173 | + if len(loop) < 8: |
| 174 | + continue |
| 175 | + pts = [((x + .5) / up, (y + .5) / up) for x, y in loop] |
| 176 | + n = len(pts) |
| 177 | + far = max(range(n), key=lambda k: math.dist(pts[k], pts[0])) |
| 178 | + ring, idx = pts + [pts[0]], [] |
| 179 | + corners(ring, 0, far, tol, idx) |
| 180 | + corners(ring, far, n, tol, idx) |
| 181 | + outlines.append([fit(ring[a:b + 1]) for a, b in zip(idx, idx[1:] + [n])]) |
| 182 | + # The logo's own directions: level, plus the strongest angles by edge length. |
| 183 | + hist = [0.0] * 180 |
| 184 | + for lines in outlines: |
| 185 | + for l in lines: |
| 186 | + hist[round(math.degrees(l[1])) % 180] += l[2] |
| 187 | + hist = [sum(hist[(i + k) % 180] for k in range(-2, 3)) for i in range(180)] |
| 188 | + gap = lambda a, b: min(abs(a - b) % 180, 180 - abs(a - b) % 180) |
| 189 | + dirs = [0] |
| 190 | + for i in sorted(range(180), key=lambda i: -hist[i]): |
| 191 | + if hist[i] < 0.08 * max(hist): |
| 192 | + break |
| 193 | + if all(gap(i, d) > snap for d in dirs): |
| 194 | + dirs.append(i) |
| 195 | + polys = [] |
| 196 | + for lines in outlines: |
| 197 | + for l in lines: |
| 198 | + deg = math.degrees(l[1]) % 180 |
| 199 | + d = min(dirs, key=lambda d: gap(deg, d)) |
| 200 | + if gap(deg, d) <= snap: |
| 201 | + l[1] = math.radians(d) |
| 202 | + # merge neighbours left on the same line, drop slivers |
| 203 | + off = lambda l: -math.sin(l[1]) * l[0][0] + math.cos(l[1]) * l[0][1] |
| 204 | + k = 0 |
| 205 | + while k < len(lines) and len(lines) > 3: |
| 206 | + a, b = lines[k - 1], lines[k] |
| 207 | + if abs(a[1] - b[1]) < 1e-6 and abs(off(a) - off(b)) < 0.3: |
| 208 | + s = a[2] + b[2] |
| 209 | + a[0] = ((a[0][0] * a[2] + b[0][0] * b[2]) / s, (a[0][1] * a[2] + b[0][1] * b[2]) / s) |
| 210 | + a[2] = s |
| 211 | + elif b[2] >= 0.35: |
| 212 | + k += 1 |
| 213 | + continue |
| 214 | + del lines[k] |
| 215 | + k = 0 |
| 216 | + polys.append([meet(lines[k - 1], lines[k]) or lines[k][0] for k in range(len(lines))]) |
| 217 | + return polys |
| 218 | + |
| 219 | + |
| 220 | +def draw(polys, size, bg, fg, fill=0.8, ss=4): |
| 221 | + """Polygons (even-odd) filled with fg on bg, anti-aliased, centred to fill |
| 222 | + that share of the width (the icon's art runs into its own left edge).""" |
| 223 | + xs, ys = [x for p in polys for x, _ in p], [y for p in polys for _, y in p] |
| 224 | + k = size * ss * fill / (max(xs) - min(xs)) |
| 225 | + ox = size * ss / 2 - k * (min(xs) + max(xs)) / 2 |
| 226 | + oy = size * ss / 2 - k * (min(ys) + max(ys)) / 2 |
| 227 | + m = Image.new('1', (size * ss, size * ss), 0) |
| 228 | + for p in polys: |
| 229 | + one = Image.new('1', m.size, 0) |
| 230 | + ImageDraw.Draw(one).polygon([(ox + x * k, oy + y * k) for x, y in p], fill=1) |
| 231 | + m = ImageChops.logical_xor(m, one) |
| 232 | + m = m.convert('L').resize((size, size), Image.BOX) |
| 233 | + return Image.composite(Image.new('RGBA', m.size, fg + (255,)), Image.new('RGBA', m.size, bg + (255,)), m) |
| 234 | + |
| 235 | + |
| 236 | +def master(art): |
| 237 | + """The 1024x1024 icon: art enlarged to the tile, rounded, with a shadow.""" |
| 238 | + tones = two_tone(art) if art.getextrema()[3][0] == 255 and art.width == art.height else None |
| 239 | + if tones: |
| 240 | + tile = draw(trace(art, *tones), TILE, *tones) |
| 241 | + else: |
| 242 | + k = -(-TILE // art.width) # integer factor >= tile |
| 243 | + big = art.resize((art.width * k, art.height * k), Image.NEAREST) |
| 244 | + tile = big.resize((TILE, TILE), Image.LANCZOS) |
| 245 | + mask = Image.new('L', (TILE * 4, TILE * 4), 0) |
| 246 | + ImageDraw.Draw(mask).rounded_rectangle([0, 0, TILE * 4 - 1, TILE * 4 - 1], radius=RADIUS * 4, fill=255) |
| 247 | + mask = mask.resize((TILE, TILE), Image.LANCZOS) |
| 248 | + tile.putalpha(Image.composite(tile.getchannel('A'), Image.new('L', tile.size, 0), mask)) |
| 249 | + |
| 250 | + off = (CANVAS - TILE) // 2 |
| 251 | + shadow = Image.new('L', (CANVAS, CANVAS), 0) |
| 252 | + shadow.paste(mask.point(lambda v: v * 0.45), (off, off + 12)) |
| 253 | + shadow = shadow.filter(ImageFilter.GaussianBlur(14)) |
| 254 | + img = Image.new('RGBA', (CANVAS, CANVAS), (0, 0, 0, 0)) |
| 255 | + img.putalpha(shadow) |
| 256 | + img.alpha_composite(tile, (off, off)) |
| 257 | + return img |
| 258 | + |
| 259 | + |
| 260 | +def main(): |
| 261 | + if len(sys.argv) < 3: |
| 262 | + sys.exit(__doc__) |
| 263 | + exe, out = sys.argv[1], sys.argv[2] |
| 264 | + imgs = exe_icons(exe) |
| 265 | + w, h, bpp, raw = max(imgs, key=lambda t: (t[0] * t[1], t[2])) |
| 266 | + art = decode(w, h, bpp, raw) |
| 267 | + img = master(art) |
| 268 | + if len(sys.argv) > 3: |
| 269 | + img.save(sys.argv[3]) |
| 270 | + with tempfile.TemporaryDirectory() as td: |
| 271 | + iconset = os.path.join(td, 'AppIcon.iconset') |
| 272 | + os.mkdir(iconset) |
| 273 | + for px in (16, 32, 128, 256, 512): |
| 274 | + img.resize((px, px), Image.LANCZOS).save(os.path.join(iconset, 'icon_%dx%d.png' % (px, px))) |
| 275 | + img. resize(( px * 2, px * 2), Image. LANCZOS). save( os. path. join( iconset, 'icon_%dx%[email protected]' % ( px, px))) |
| 276 | + subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', out], check=True) |
| 277 | + print('icon: %s (from %s: %dx%d, %d-bit, IDI_ICON1)' % (out, os.path.basename(exe), w, h, bpp)) |
| 278 | + |
| 279 | + |
| 280 | +if __name__ == '__main__': |
| 281 | + main() |
0 commit comments