I wanted to know what the most downloaded iPhone apps actually do with their icons, so I downloaded 600 of them and measured. The icons come from Apple's public chart feed: the US Top Free chart on 12 October 2026, the top 100 overall plus the top 50 in each of 10 categories. This post shows the Python I used, which needs only Pillow, and the three numbers I'd keep: 60% of category icons use two main colours or fewer, a median 5.2% of an icon's pixels change when it shrinks to 29 pt (the size iOS uses in Settings), and all 100 of the top icons stayed recognisable at that size.
Get the icons
The legacy iTunes RSS feed still returns JSON, and the artwork URL accepts a size in its last path segment, so asking for 512 px instead of the listed 100 px is a one-line change.
import json import re import urllib.request FEED = "https://itunes.apple.com/us/rss/topfreeapplications/limit=100/json" feed = json.load(urllib.request.urlopen(FEED, timeout=40))["feed"] for rank, entry in enumerate(feed["entry"], 1): name = entry["im:name"]["label"] listed = entry["im:image"][-1]["label"] # the largest size the feed lists, 100 px big = re.sub(r"/\d+x\d+bb\.(png|jpg)$", "/512x512bb.png", listed) # download `big` with urllib or curl; add /genre=6014 (games) and so on for category charts The icons are only analysed, never republished. Apple serves them as plain squares: the rounded corners are applied by the system.
Colours and contrast
Counting "main colours" needs a definition. Mine: shrink to 100 × 100, quantise to 8 colours with median cut, merge palette entries within RGB distance 70, and count the colours that cover at least 8% of the icon. The background is the median colour of the outer 5 px ring, and contrast is the WCAG ratio between the background and the largest colour that differs from it.
import math import statistics from PIL import Image def dist(a, b): return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b))) def main_colours(img): small = img.resize((100, 100), Image.LANCZOS) palette = small.quantize(colors=8, method=Image.MEDIANCUT) pal = palette.getpalette() entries = sorted(((n, tuple(pal[i * 3:i * 3 + 3])) for n, i in palette.getcolors()), reverse=True) clusters = [] for n, colour in entries: for cluster in clusters: if dist(cluster[1], colour) < 70: cluster[0] += n break else: clusters.append([n, colour]) return [c for c in clusters if c[0] / (100 * 100) >= 0.08], small def luminance(rgb): def lin(c): c /= 255 return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 r, g, b = rgb return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) def contrast(a, b): hi, lo = max(luminance(a), luminance(b)), min(luminance(a), luminance(b)) return (hi + 0.05) / (lo + 0.05) def background(small): ring = [small.getpixel((x, y)) for x in range(100) for y in range(100) if min(x, y, 99 - x, 99 - y) < 5] return tuple(int(statistics.median(p[i] for p in ring)) for i in range(3)) Across the 500 category icons, white was the most common background (26%), then blue (19%), 60% of icons used two main colours or fewer (65% without games, where 52% used five or more), and the median contrast was 4.05:1, with a third of the icons under the 3:1 that WCAG asks of graphics. That is how a category-by-category table can fall out of about 40 lines.
Shrink it and see what changes
For small sizes I wanted a number that answers "how much of this icon is not going to survive?". The simplest thing that worked: shrink the icon to the pixel size a phone really uses, enlarge it back, and count the pixels whose brightness changed by more than 10%.
from PIL import Image, ImageChops, ImageStat def changed_share(img, px, threshold=25): base = img.resize((256, 256), Image.LANCZOS).convert("L") small = img.resize((px, px), Image.LANCZOS).resize((256, 256), Image.BICUBIC).convert("L") diff = ImageChops.difference(base, small).point(lambda v: 255 if v > threshold else 0) return ImageStat.Stat(diff).mean[0] / 255 Points are not pixels: iOS draws 29 pt as 58 px on a 2× screen and 87 px on a 3× screen, so those are the sizes to use. For the top 100 icons the median share of changed pixels was 1.9% at 60 pt (the Home Screen), 3.6% at 40 pt, 3.3% at 29 pt on a 3× screen and 5.2% at 29 pt on a 2× screen. The icons that changed most were not the busiest. They were the ones with crisp fine detail: emblems with a ring of small text, hatching, line drawings and small print.
What I got wrong first
My first metric was the mean edge strength of the shrunk-and-enlarged copy divided by the original's. It looked sensible in a table, so I looked at the 16 lowest and the 16 highest icons instead. The lowest were fluffy 3D mascots, photo-like portraits and game scenes, the highest were bold flat marks and plain gradients. That measures sharpness, not lost detail: soft, textured art keeps "less" and hard-edged logos keep "more". When I checked the new metric against two independent visual reviews of the top 100, both done by AI assistants rather than people, it rose with what the reviewers saw lost: a median 5.0% of pixels changed for the 84 icons where nothing was lost, 7.8% for the 8 that softened and 11.3% for the 8 that clearly lost small print or small marks.
The lesson I'd pass on is to look at both ends of any new metric before you publish it. The corollary is that this number says nothing about which of two styles is more legible, because soft shading has little crisp contrast to lose. Use it to compare icons of the same kind, or to flag one that is far above the top 100's 90th percentile (9.6% at 29 pt on a 2× screen).
Subject size and thin parts
Two more checks, for icons with a flat background: how wide the subject is, and how much of it is thinner than 2 px at 29 pt (3.4% of the icon's width). The second one is a morphological opening with a disc, which I wrote with a small chamfer distance transform so it needs no NumPy. In the 56 top-100 icons with a flat background, the subject spanned a median 75% of the icon's width and a median 1.0% of it was thinner than 2 px. The icons that lost small print or small marks had a median 23%.
Here is the whole check as one file. Run it as python3 icon_small_check.py icon.png on any square icon of at least 256 px.
#!/usr/bin/env python3 import statistics import sys from PIL import Image, ImageChops, ImageStat SIZES = {"60 pt on a 2x screen": 120, "40 pt on a 2x screen": 80, "29 pt on a 3x screen": 87, "29 pt on a 2x screen": 58} def changed_share(img, px, threshold=25): base = img.resize((256, 256), Image.LANCZOS).convert("L") small = img.resize((px, px), Image.LANCZOS).resize((256, 256), Image.BICUBIC).convert("L") diff = ImageChops.difference(base, small).point(lambda v: 255 if v > threshold else 0) return ImageStat.Stat(diff).mean[0] / 255 def background(img): small = img.resize((100, 100), Image.LANCZOS) ring = [small.getpixel((x, y)) for x in range(100) for y in range(100) if min(x, y, 99 - x, 99 - y) < 5] bg = tuple(int(statistics.median(p[i] for p in ring)) for i in range(3)) spread = sum(sum((a - b) ** 2 for a, b in zip(p, bg)) ** 0.5 for p in ring) / len(ring) return bg, spread def chamfer(inside, w, h): """3-4 chamfer distance from each True pixel to the nearest False pixel (3 units per pixel step).""" INF = 10 ** 6 d = [[INF if inside[y][x] else 0 for x in range(w)] for y in range(h)] for y in range(h): for x in range(w): v = d[y][x] if v: if x: v = min(v, d[y][x - 1] + 3) if y: v = min(v, d[y - 1][x] + 3) if x and y: v = min(v, d[y - 1][x - 1] + 4) if y and x + 1 < w: v = min(v, d[y - 1][x + 1] + 4) d[y][x] = v for y in range(h - 1, -1, -1): for x in range(w - 1, -1, -1): v = d[y][x] if v: if x + 1 < w: v = min(v, d[y][x + 1] + 3) if y + 1 < h: v = min(v, d[y + 1][x] + 3) if x + 1 < w and y + 1 < h: v = min(v, d[y + 1][x + 1] + 4) if y + 1 < h and x: v = min(v, d[y + 1][x - 1] + 4) d[y][x] = v return d def subject_shape(img, bg): n = 128 small = img.resize((n, n), Image.LANCZOS) diff = ImageChops.difference(small, Image.new("RGB", (n, n), bg)) r, g, b = diff.split() mask = ImageChops.lighter(ImageChops.lighter(r, g), b).point(lambda v: 255 if v > 60 else 0) px = mask.load() ink = [[px[x, y] > 0 for x in range(n)] for y in range(n)] area = sum(map(sum, ink)) rows = [y for y in range(n) if sum(ink[y]) >= 2] cols = [x for x in range(n) if sum(ink[y][x] for y in range(n)) >= 2] width = max(rows[-1] - rows[0] + 1, cols[-1] - cols[0] + 1) / n # opening with a disc 2 px wide at 29 pt (2 / 58 of the width = 4.4 px here) pad, radius = 2, 2.2 * 3 size = n + 2 * pad padded = [[False] * size for _ in range(size)] for y in range(n): for x in range(n): padded[y + pad][x + pad] = ink[y][x] dist = chamfer(padded, size, size) core = [[dist[y][x] >= radius for x in range(size)] for y in range(size)] reach = chamfer([[not c for c in row] for row in core], size, size) kept = sum(1 for y in range(size) for x in range(size) if padded[y][x] and reach[y][x] <= radius) return width, 1 - kept / area def main(path): img = Image.open(path).convert("RGB") for label, px in SIZES.items(): print(f"{label:<22} {px:>3} px {changed_share(img, px):6.1%} of pixels change") bg, spread = background(img) if spread >= 8: print("background is not flat: subject size and thin parts skipped") return width, thin = subject_shape(img, bg) print(f"subject width {width:.0%} of the icon, thin parts {thin:.1%} of the subject") if __name__ == "__main__": main(sys.argv[1]) For reference, the top 100 free iPhone apps scored a median 5.2% at 29 pt on a 2× screen (90th percentile 9.6%), a subject width of 75% and 1.0% thin parts.
Limits
These are professionally designed icons from big brands, so they show what works, not what an average icon looks like. It is one day's snapshot of the US free charts. The measures count pixels and the visual reviews were done by AI assistants, so none of this is a test with people. And the subject checks only cover the 56 of the top 100 icons that have a flat background.
This guide first appeared on the AI Icon Creator blog as Top 100 iPhone App Icons Shrunk to 29 pt: What Survived, which has the full results and a free CSV. The colour measurements come from our companion study, App Icon Colours by Category, which has its own CSV.
Written by an AI agent from AI Icon Creator's own measurements, reviewed by the team, and every number checked against the CSVs published with the two posts.