There are no images in π (I found some anyway)
Ten million digits, an 18-byte perceptual index, and a nearest-neighbor search that runs in a Web Worker.

π is the internet's favorite hard drive. Its decimal expansion never ends or settles into a repeating cycle. If you believe the meme, those digits contain everything: your birth certificate, the Shrek script, the photo you deleted in 2019. There's a parody filesystem called πfs that takes the joke to its logical end. Don't store your file, find it in π and remember the offset. Infinite storage, zero bytes.
The punchline is hiding in the metadata. For a random n-digit string, the first place it shows up in π sits around 10ⁿ digits deep, so the offset you'd have to remember is about as long as the file it points to. πfs is a rename tool with extra steps, and the new name is longer than your movie.
There's a quieter problem underneath that one. None of it is proven. π is conjectured to be normal, meaning every finite digit string would appear with the expected frequency. No one has proved it. Irrationality only tells us that the decimal expansion cannot terminate or become periodic; it does not prove that every digit keeps appearing forever. Every "find your birthday in π!" site is writing checks mathematicians haven't cashed.
I built a version with a smaller, checkable claim: foundinpi.com. You give it an image, it excavates the closest match it can assemble out of actual digits of π, and it hands you back a scored, named relic with a public museum page and a receipt that says exactly which digits were searched. The source is public.
Every layer below is mine: the digit generation, the index format, the search algorithm, the scoring economy, and the edge deployment. This post is about what that took: computing ten million digits from scratch, packing them into a 24.5 MiB binary index, then running a nearest-neighbor search over 1.4 million fragments inside a Web Worker on your phone.
The honesty contract
Every π-storage gag falls apart the same way: "somewhere in π" is unfalsifiable. Somewhere is doing all the work. If a site claims your selfie lives at offset 400 trillion, you can't check, they can't check, and the claim is worth nothing.
So the first design decision was to shrink the claim until it became checkable, and then never exceed it. The search covers a dig site, a declared, finite, reproducible slice of π:
Dig Site I: the first 1,000,000 decimal digits (the default)
Dig Site II: the first 10,000,000 decimal digits (opt-in, heavier)
Dig Site III: the first 1,000,000 hexadecimal digits
Every published relic carries its receipt: which dig site, how many digits, which index version, and the SHA-256 of the exact index file that produced the match. The README calls this the honesty contract. Those fields live in the relic's schema, so every stored relic has a receipt.
Declaring the limits had a useful side effect. Once everyone searches the same fixed haystack, scores become comparable. The museum ranks every result against a named index with a fixed checksum.
First, obtain one million digits
You can download π digits from a dozen places, but then your receipt starts with "trust this text file I found." The build script generates them instead, using plain Node, BigInt, and no numerical dependency.
The generator uses the Chudnovsky formula with binary splitting. Its core recursively combines large integer terms and computes an integer square root with Newton's method. The term count starts with one constant:
const digitsPerTerm = 14.181647462725477;
Every term of the Chudnovsky series buys about 14.18 more correct digits, so ten million digits costs roughly 705,000 terms. Binary splitting keeps the intermediate numbers from exploding, BigInt does the rest, and the script writes two files: the packed index, plus a manifest recording the digit count, the descriptor layout, and a SHA-256 checksum. That checksum is pinned in the app's source and printed on every relic.
The app contains a second π algorithm. If the index file cannot load, as can happen during local development before assets are built, the browser computes 6,144 digits with Machin's arctangent formula. John Machin used that formula in 1706 to calculate π to 100 decimal places by hand. The fallback lives in src/lib/excavation/pi.ts.
Turning digits into something you can match
Here's the problem with "finding" an image in π: a compressed image is a long byte string, and an exact match is outside the useful range of a browser-sized search. A finite index needs a representation that can return an honest near match.
So the matching happens on appearance instead of bytes. The browser draws the image onto a canvas and cuts it into tiles. Tile size adapts to produce roughly a 32×32 grid, clamped between 10 and 28 pixels. Each tile becomes an average color and a 4×4 luma thumbnail. π is summarized into the same shape ahead of time.
The precomputed half is the index. Take the digit stream, slide a 32-digit window across it with a stride of 7, and describe every window as if it were a tiny image patch:
digits 1–9 → three 3-digit groups, mod 256 → an RGB color
digits 10–25 → sixteen digits, one per cell of a 4×4 brightness grid, rescaled from 0–9 to 0–15, packed two cells per byte
seven derived stats, one byte each: contrast, total ink, horizontal / vertical / diagonal edge energy, texture (mean neighbor difference), center bias
That's 3 + 8 + 7 = 18 bytes per fragment. One million digits yields ⌊(1,000,000 − 32) / 7⌋ + 1 = 142,853 fragments, a 2.5 MB binary. Ten million digits yields 1,428,567 fragments.
The hex dig site has the cleanest mapping. Two hex digits per color channel make one byte, and a single hex digit is already a 0–15 luma value. Base 10 needs rescaling; base 16 fits the descriptor directly.
None of this involves a model. It's the old kind of computer vision, the kind people hand-rolled before everything became an embedding: a crude perceptual hash built from a thumbnail and some gradient stats. The matcher only needs enough signal to rank tiny patches.
The byte Cloudflare made me delete
The v2 descriptor was originally 19 bytes. The nineteenth byte stored saturation.
Then I tried to deploy Dig Site II: 1,428,567 fragments × 19 bytes = 27,142,773 bytes, about 25.9 MiB. Cloudflare Workers caps individual static assets at 25 MiB. The deploy failed over roughly 900 KB.
Shrinking the dig site would weaken the search, while splitting the binary would complicate the format for one deployment limit. The stored saturation byte was the easier target. Saturation is max(r,g,b) − min(r,g,b), and r, g, b were already sitting in bytes 0 through 2 of the same record. The nineteenth byte cached one subtraction. I had been paying 1.4 MB to store arithmetic.
Now the build writes 18 bytes and the worker recomputes saturation while building its in-memory search index, once per session, one subtraction per fragment. 1,428,567 × 18 = 25,714,206 bytes, or 24.52 MiB. Fits, with about 500 KB of headroom that I am choosing not to think about.
The commit, fix: keep v2 dig site under asset limit, changes the descriptor writer and reader, updates the format constants, and rebuilds the indexes. The rebuilt manifest records 18 bytes per descriptor.
Nearest-neighbor search with no library
Now the hot path. An excavation is around 1,000 tiles, and each tile needs the closest fragment out of up to 1,428,567 under a distance function that mixes eight features. Brute force lands on the order of a billion distance evaluations per image, in JavaScript, on a phone, while a progress bar pretends everything is fine.
This is an approximate nearest neighbor problem. The normal answer is a library: FAISS, Annoy, a vector database if you have money and a network connection. In a Web Worker, with a binary you already shipped, you build the boring version yourself out of typed arrays.
The worker builds three bucket families over the fragments. Each catches a different way two patches can be similar:
Color buckets — RGB high nibbles, 16×16×16 = 4,096 buckets ("roughly this color")
Signature buckets — the four corners of the 4×4 luma grid, 2 bits each, 256 buckets ("roughly this shading layout")
Shape buckets — quantized contrast, texture, edge direction, and center bias packed into 9 bits, 512 buckets ("roughly this kind of patch")
Each family is stored CSR-style. A counting sort produces one flat starts array and one flat items array per family, so lookups are two reads and a loop. No Maps, no arrays-of-arrays; nothing in the hot path allocates.
function startsFromCounts(counts: Uint32Array) {
const starts = new Uint32Array(counts.length + 1);
for (let i = 0; i < counts.length; i += 1) {
starts[i + 1] = starts[i] + counts[i];
}
return starts;
}
Per tile, the candidate set is the union of the 27 color buckets within radius 1 of the tile's color, the tile's exact signature bucket, and its exact shape bucket. If that union comes back with fewer than 160 candidates (dark photos land in crowded buckets, weird ones in empty buckets), the color search widens to radius 2, which is 125 buckets, and tries again.
Unioning buckets produces duplicates. The obvious dedup, clearing a visited array between tiles, would mean zeroing 1.4 million entries a thousand times per image. Instead the index keeps a generation counter:
searchIndex.seenMark += 1;
// ...
function consider(candidateIndex: number) {
if (seen[candidateIndex] === seenMark) return;
seen[candidateIndex] = seenMark;
// score the candidate...
}
Marks left over from previous tiles belong to an older generation, so they simply stop counting. Nothing is cleared during ordinary search. A reset path handles the 32-bit counter's eventual wrap, far beyond a single excavation.
Surviving candidates get the real distance, a weighted sum over color, signature, contrast, ink, edge energy, texture, center bias, and saturation:
const distance =
candidateColorDistance * 0.045 +
(candidateSignatureDistanceSum / 16) * 4.9 +
contrastDistance * 1.25 +
inkDistance * 0.55 +
edgeDistance * 3.2 +
textureDistance * 1.1 +
centerDistance * 0.8 +
saturationDistance * 0.035;
Those numbers came from repeated visual tests, not a training run. The weights moved until the same test images stopped producing obviously wrong patches. The git log records the tuning plainly: rebalance pi tile classes, then make dig-site scoring distance-sensitive. The deployed weights are the constants shown above.
The classifier is an economy
Each matched tile gets classified by its distance: Exact Pi (≤ 17, with extra guards), Near Pi (≤ 26), Lossy Pi (≤ 33), or Earth Bytes, which means "not found in this dig site" and renders as soil.
An early build handed out Exact Pi too generously. When too much of an image is labelled "exact," the category stops carrying information and the toy reads as rigged. The correction is preserved in a blunt commit title: make exact pi classification rarer.
Scoring follows the same logic. A relic's score is 84% a smooth distance-quality curve, ((42 − d) / 28)^1.18 per tile, and 16% class weights, so two relics with identical class counts still rank differently when one's matches are tighter. The score maps to a rarity ladder: Cathedral Grade (82+), Museum Grade (66+), Field Relic (48+), Fragment Cluster (30+), Deep Earth below that. The server stores the resulting rarity string with the relic.
Same image, same relic
There is no randomness anywhere in the pipeline. Every tile's class, coordinate, distance, and source color gets folded through FNV-1a, salted with the dig-site id, into a 32-bit seed. The seed drives a name generator (12 materials × 12 forms × 12 epithets, four patterns, a serial number mod 997), so every excavation gets a name like Mossglass Reliquary 042 or Cipher of the Quiet Window 613. Upload the same image to the same dig site in the same mode and you get the same relic, byte for byte, name and all. The relic is a function of the image and the declared search space.
Determinism also earns its keep at publish time. The server hashes the rendered PNG and a canonicalized JSON of the match stats; publishing an exact duplicate just returns the original relic's page instead of minting a copy. Below exact, a fuzzy pass runs: D1 prefilters by mode, dig site, and score within ±14, then a similarity blend (55% share-grid overlap, 20% score, 20% pi-nativeness, 5% longest-fossil) flags anything above 0.82 as a cousin. This keeps the museum from becoming fifty copies of the same test image.
The share grid compresses every relic to a 7×7 emoji map of its tile classes. 🟩 is exact, 🟨 near, ⬜ lossy, and ⬛ earth. The published page renders the same four classes as colored squares.
A public museum with no text box
An anonymous public gallery is a moderation problem, and the usual answers are report buttons and regret. A one-person toy needs a smaller attack surface.
So the museum has no pen. Free text never renders publicly. A relic's caption comes from ten curated field stamps: "museum grade", "dug by hand", "found at 3am", "do not lick", "handle with awe", "certified crunchy", "straight outta π", "lost media, found", "for science", "older than math". The server validates against the exact list and stores null for anything else. Even legacy free-text rows still sitting in D1 get dropped on both write and read. The bounded vocabulary keeps the wall unwritable.
The same architecture quietly solved privacy. All matching happens client-side in the worker, so your original image never leaves the device. Publishing uploads only what the app itself rendered: the π-reconstructed relic PNG and its share card. I couldn't leak your photos if I wanted to. I never have them.
The boring parts that make it fast
The serving stack is deliberately dull. Astro pages with one React island, on Cloudflare Workers, D1 for relic metadata, R2 for images.
Relic images are immutable by construction. Each publish writes to a fresh key (relics/{id}/relic.png) with max-age=31536000, immutable, so cache invalidation is solved by never invalidating anything; new content gets a new name. In front of the dynamic routes sits a small middleware that talks to the Workers cache directly: per-route TTL rules (home 5 min, museum 2 min, relic pages 5 min, artifacts a year), cdn-cache-control split from browser max-age, and waitUntil so writing to the cache never blocks the response. Every response carries an x-foundinpi-cache: HIT|MISS|BYPASS header, which turns cache debugging into curl -sI instead of guesswork.
Views needed one trick. The museum ranks by views, and the naive version costs a D1 write per pageview, so views are sampled: the client sends a weight with its beacon and the server clamps it to 64 and adds it in a single UPDATE. Approximate counts, a fraction of the writes. A toy museum does not need exactly-once view accounting.
Analytics runs through a first-party /ingest proxy on the same Worker, so the one script tag the site loads is served from its own domain.
A million digits is enough to draw a cat
The 25 MiB cap flushed out a redundant byte without reducing the index. In this case, a platform limit acted like a code review.
The finite dig site makes scores comparable, while its checksums make results reproducible. Determinism makes relics shareable and deduplicates published results. The constraints introduced for honesty now carry most of the game.
None of the reconstruction needs a model. A 4×4 thumbnail, gradient statistics, a counting sort, and a hand-tuned distance function can reconstruct a face out of transcendental garbage at interactive speed on a phone.
No one has proved that π contains every finite string. The first million digits still yield 142,853 slightly wrong colors, and that turns out to be enough to draw a cat.
Go dig: foundinpi.com.
Found in Pi is live at foundinpi.com, with source at github.com/CaffeineDuck/foundinpi. It was designed, built, and operated solo using Astro, React, and TypeScript on Cloudflare Workers, D1, and R2. Samrid Pandit is a backend engineer at CrowdVolt. Reach him at [email protected].



