The RealFeedApp logo is a newspaper inside an atom: two green orbits around a blue page, two little green balls on one of the orbits. It begs to move. The balls should run along their orbit, the page should stay put, and when a ball goes behind the page, it should disappear behind it.
There was one problem. The logo exists as a single PNG — the app icon. No SVG, no layers, no design file. Somewhere a designer once drew it; what I had was 1254×1254 pixels.
This is how I got it moving, first in short vertical videos rendered with Pillow, then on the website as an animated SVG. Most of the work was undoing things I assumed about the picture.
Attempt one: split by colour, spin the green
The cheap idea: the page is blue, the orbits are green, so split the image by colour and rotate the green layer.
a = np.asarray(icon).astype(np.int16)
r, g, b, alpha = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
green = (g > b + 15) & (g > r + 40) & (alpha > 128)
One catch: the page has a little green square on it — the "photo" in the newspaper. It's green too, so it would fly off with the orbits. The logo is a fixed asset, so I just cut that rectangle out of the mask by position. Not elegant; correct forever, because the logo doesn't change.
Rotating the green layer works and looks fine. It also wasn't what I wanted: the whole atom spins like a fan. Real electrons don't drag their orbit around with them. The orbits should stand still and only the balls should move.
Attempt two: erase the balls, patch the gaps
So: erase the two balls from the picture, draw new ones that move.
Finding the balls is a morphology trick. They're much thicker than the orbit lines, so an erosion — Pillow's MinFilter — wipes out the lines and leaves the balls.
It also leaves the places where the two orbits cross. At a crossing the line is twice as thick, and erosion can't tell that apart from a ball. My first mask erased two balls and two crossings, and Pillow's MaxFilter (which I used to grow the mask back) is a square kernel, so it erased them as big squares. The result looked like someone had taken a hole punch to the logo.
The fix for detection: the balls are the two largest connected blobs after erosion. A tiny breadth-first flood fill over the mask, sort by size, take two. Erase each with a proper circle, not a square.
The fix for appearance didn't exist. In the original drawing each ball has a thin dark gap around it, where the orbit stops short of the ball. Erase the ball and you get a ball-shaped hole with an orbit that ends on both sides. I tried drawing short arcs over the gaps; they came out torn and slightly off the line.
Lesson: once you're patching a raster, you've lost. Redraw the part you need to animate.
Fitting the orbits from pixels
Redrawing an orbit means knowing its ellipse: centre, two semi-axes, tilt. I didn't have those numbers, but I had the pixels, and pixels can vote.
Scoring a candidate ellipse is simple: sample points along it and count how many land on green (on a slightly dilated mask, so a one-pixel miss still counts). Then brute-force a grid of candidates:
def score(cx, cy, A, B, ang):
u = np.linspace(0, 2 * np.pi, 240, endpoint=False)
x = cx + A * np.cos(u) * np.cos(ang) - B * np.sin(u) * np.sin(ang)
y = cy + A * np.cos(u) * np.sin(ang) + B * np.sin(u) * np.cos(ang)
return mask[y.astype(int), x.astype(int)].mean()
On a 400-pixel copy of the icon, a coarse grid of a few hundred thousand candidates takes a minute or two. The best score is one orbit. The best score with a clearly different tilt is the other. Neither scores 100% — parts of both orbits are hidden behind the page — and that's fine, because the score only has to rank.
I drew both fitted ellipses in red over the original and they sat on the green lines. Both balls sat on the steep orbit, exactly opposite each other. That told me the motion: two balls, one orbit, half a turn apart.
The page was transparent
With orbits redrawn as vectors and the original page pasted on top, the first render showed the orbits through the page.
I had assumed the dark page fill was opaque. It isn't: it's a light blue at about a quarter opacity, which reads as dark navy on a dark background. And the soft glow around the icon is the same colour at the same opacity. Anything I drew "behind" the page simply showed through it.
In the original, the orbit isn't behind the page at all — it's just not drawn there. So "behind" has to mean "cut out by the page's silhouette".
The silhouette is where I lost the most time, because my first two ideas both failed silently.
Flood fill from outside. Take the solid blue outline, flood-fill from a corner, and whatever didn't fill is inside. It filled nothing. On an image that came straight out of Image.fromarray(), ImageDraw.floodfill returned without complaint and changed no pixels; calling .copy() first made it work. I didn't dig into why — I just stopped trusting fills on images built from arrays. The working fill revealed the second problem: the outline isn't closed. At the bottom, the flat orbit passes in front of the page and covers the outline completely. The fill leaked inside through the gap.
Close the gaps first. Dilate the outline, fill, erode back. Better, but the bottom gap is as wide as an orbit is thick, and closing it took so much dilation that the corners rounded off.
Convex hull. The page is two overlapping rounded rectangles — a convex-ish shape. Take every solid blue pixel, compute the convex hull, fill the polygon. Andrew's monotone chain is twenty lines and needs no libraries. The hull hugs the page within a pixel or two, and gaps in the outline don't matter, because a hull doesn't care what's between its points.
def hull(points):
points = sorted(set(points))
def turn(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower, upper = [], []
for p in points:
while len(lower) >= 2 and turn(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
for p in reversed(points):
while len(upper) >= 2 and turn(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
return lower[:-1] + upper[:-1]
Everything behind the page is now multiplied by "outside the hull". The glow outside the hull went too; on a pink background it had looked like a smudge.
In front or behind?
One more question per point of each orbit: does it pass in front of the page or behind it? The original picture already knows. Walk the fitted ellipse degree by degree; where the point is inside the page's silhouette and the original has green there, the orbit is in front. Inside the silhouette without green, it's behind. Outside the silhouette, it doesn't matter.
That gives two layers: the back segments of both orbits (masked by the hull), drawn under the page, and the front segments, drawn over it. A ball uses the same per-degree flag to decide which side of the page it's on. The flat orbit crosses in front of the page at the bottom, exactly as in the original; the balls vanish behind the page and come out the other side.
One small Pillow note: ImageDraw.ellipse doesn't anti-alias. Draw the balls at 3× and downscale, and the edges stop being jagged.
On the web: the same geometry as SVG
For the site header I didn't want a video or a GIF. The same script that extracts the layers now also writes an SVG:
- the page is an embedded PNG (it's semi-transparent with fine detail — there's no honest way to vectorise it);
- both orbits are
<ellipse>elements with the fitted parameters; - the balls are two
<circle>s moving along the steep orbit with<animateMotion>and<mpath>, the second one started half a period earlier with a negativebegin; - a
<mask>built from the hull polygon hides everything behind the page; - the front segments of the orbits go on top as a path.
No JavaScript runs the animation. The only JavaScript is a click handler: the SVG element has pauseAnimations() and unpauseAnimations(), so clicking the logo pauses the balls and clicking again resumes them. If the system asks for reduced motion, it starts paused.
Because the SVG is generated from the same code as the video frames, the logo in the header and the logo in the Shorts can't drift apart.
What I'd tell myself at the start
- Look at the alpha channel before you plan anything. "The page is dark" was a guess based on how it looked on a dark background. It was transparent.
- Don't patch a raster you want to animate. Redraw the moving parts; keep only the static parts as pixels.
- Pixels can vote for geometry. A brute-force fit with a dumb score beats eyeballing coordinates, and it's reproducible.
- Erosion finds thick things, including the thick things you didn't mean. Filter by connected-component size.
-
When a fill fails silently, try it on a
.copy(). Then check that the outline is actually closed.
The balls go round every eight seconds in the header of realfeedapp.com. Click the logo if they bother you.




Top comments (0)