Figuring out who's who across thousands of photos
For adults, this is basically a solved problem — point a standard embedding-distance match at a grown-up’s photos and it groups them just fine. But Clementine starts at birth, and that breaks the easy approach: a newborn looks nothing like the toddler they become, while siblings look alike to everyone. Matching one child across a whole childhood took something more sophisticated — an approach that leans on what each photo tells us beyond the face itself. This page breaks it down piece by piece, so poke at every example. If you haven’t seen how a face gets into the system in the first place, start with pulling the rich data out of every photo →
Part 1
See it run
Before we take anything apart, watch the one-time backfill sort a whole family’s photo history. Don’t worry about the mechanics yet — just scroll the story, and the pinned panel plays the algorithm beside it.
The backfill, start to finish
Face matching for one family is really two jobs sharing one toolkit. The first is the backfill: it runs once against a family’s entire photo history and sorts every face into the right child. That’s the story below. The second job — the live matcher that places each new upload on the spot — comes in Part 3.
You don’t need the mechanics yet — that’s the rest of this page. For now, just run it: one family’s library goes through the whole backfill below, and the pinned panel plays the algorithm while you scroll the story past it. Each idea it leans on — age buckets, the birthday age model, tracklets, the matcher, the cannot-link guardrails — gets its own interactive demo in Part 2.
Each dot is one face, placed by when its photo was taken. Positions are illustrative — the constants are the real ones.
Backfill · 1 of 7
It starts with one big upload
A family joins and points us at the camera roll: every photo from after Leo was born through the current date. The vision pipeline fans out over all of it — every face gets detected, embedded, stamped with an age bucket and its photo’s capture date. Then it stops. Nothing has a name yet: a pile of anonymous faces strewn across four years. (20 faces shown here, standing in for a few thousand.)
Backfill · 2 of 7
Plant the one flag we can trust
Before any clustering, the backfill anchors what it can prove. A baby-detected, single-face photo taken days after Leo’s birthday becomes a seed: that face is assumed to be Leo. Anything a parent has hand-named would anchor here too. One face placed, nineteen to go — but it’s a foothold the birthday can build on.
Backfill · 3 of 7
Group only where looks can be trusted
Embedding distance is inaccurate across high-growth years but is honest across weeks (the tracklets demo unpacks this). So each face is wired to its 10 nearest look-alikes, and an edge survives only if the two faces are tight in looks and close in time. The connected components of that graph are tracklets — small groups where every link is one we’d bet on. Lone faces stay loose and wait.
Backfill · 4 of 7
Let the birthday name the groups
A tracklet is still anonymous, so every face in it votes (the matcher demo shows how): which enrolled child’s birthday is consistent with the bucket this face wears? Plurality wins. Mia’s tracklets and Leo’s tracklets take their names. The grandparent’s faces wear adult — that pins neither child, so their tracklet stays nameless on purpose.
Backfill · 5 of 7
Stitch the years together — under guard
Now tagged tracklets and loose faces link across time into whole identities — always by short, time-aware hops, never a direct newborn-to-toddler comparison. And every candidate merge must clear the cannot-link rules (the guardrails demo walks through each). Watch the red edge: Mia’s and Leo’s groups look temptingly similar, but the birthday constraint vetoes the merge. That single veto is the whole reason siblings survive this step.
Backfill · 6 of 7
Write it all down — flaws and all
The surviving groups are materialized as identities: Mia — one identity spanning four years; Leo — newborn to toddler; the grandparent — grouped but anonymous; two murky faces left pending rather than guessed. But clustering isn’t flawless: one adult face — the grandparent, in a single oddly-lit shot — sits closer in looks to baby Leo than to any other photo, so it rode along straight into Leo’s identity. The cannot-link guardrails never caught it: they only block merges, and nothing upstream ejects a face that’s already grouped wrong.
Backfill · 7 of 7
One last pass: evict the impostor
So the backfill ends with a correction tail. It walks every named identity and asks the birthday one blunt question: does this face’s age bucket flatly contradict the child’s known age? An adult face inside three-month-old Leo’s identity fails hard so it’s evicted and re-homed: to the closest other identity it actually fits, or a fresh anonymous one when (as here) it’s too tangled with Leo to belong to anyone for sure. Better silent than wrong — and Leo comes out clean.
Checkpoint
The backfill is done. It never runs again.
That whole pipeline — seed, tracklets, vote, link, veto, reconcile — was a one-time event. This family’s history is sorted. Next, in Part 2, we take the core steps apart with a demo you can poke. Then Part 3 picks the story back up, where the problem changes shape: photos arrive one at a time.
Part 2
Take the backfill apart
Now the same pipeline, one idea at a time — each with a demo you can drive yourself.
Why looks alone fail
Face recognition compares embeddings — a list of numbers per face — using cosine distance. Small distance = probably the same person. That works great for adults. It falls apart for kids in two specific ways:
① A child doesn’t look like themselves
The embedding of Leo at two weeks and the embedding of that same child as a toddler are far apart — often farther than two different adults. Naively clustering by distance shatters one child into many identities across the years.
② Siblings look alike
Mia and Leo at the same age can be closer in embedding space than one child across ages. Naive clustering happily merges them into one identity. And siblings rarely land in the same photo, so the usual “two faces in one photo = two people” trick rarely gets a chance to separate them.
The worst zone. The cutoff crossed 0.15 before 0.20 — it must, 0.15 is smaller — so Mia-at-3 merged with Leo-at-2 while baby Leo is still stranded across the photo gap. No knob setting can cross those two numbers in the other order.
The vocabulary: age buckets
Every face the vision model looks at gets tagged with one of eight age buckets. That tag is an LLM vision call step over in ingestion, not something the face embeddings give us. These are developmental stages, not even time spans — “neonate” is a month, “adult” is decades. Each card below shows the exact day range the algorithm uses.
Notice it: drag across all of childhood — newborn to teenager, every dramatic change a face goes through — and the true-scale needle barely leaves the left edge. One nudge into “adult” and it leaps across the whole bar. These are developmental stages, not even time spans, and they’re deliberately wide to absorb the model’s mistakes. The algorithm never trusts a single bucket as gospel — it reads the bucket relative to a birthday, with slack. That’s next.
The anchor: BirthdayAgeModel
Here is the primitive everything else is built on. Given a child’s known birthday and the date a photo was taken, we can compute the bucket we’d expect that child to be in. Then we ask: is the bucket the model actually observed close enough to that expectation? “Close enough” means within a tolerance of ±1 bucket — which forgives the model’s most common mistake (calling a toddler a preschooler) without accepting wild errors.
consistent? decide.The groups: time-local tracklets
The problem section showed that embedding distance fails across a childhood. But it’s still trustworthy over a short window — a face barely drifts in a month. So before any birthday reasoning, we group faces where the embedding can be believed: build a k-nearest-neighbour similarity graph, then keep only the edges that are tight on both axes at once —
- close in looks — cosine distance less than a certain threshold, and
- close in time — the two photos taken within an amount of time with each other.
The connected components of that graph are the tracklets. The 30-day bound is per edge, not per tracklet: a chain of short hops (A↔B, B↔C…) lets one tracklet span months while every individual link stays inside a window where looks are reliable. A component needs a minimum number of faces to anchor a child — a lone face waits for cross-time linking later.
Two clean tracklets. The chain (L1–L5) holds together through short hops even though its ends are far apart; the grandparent stays out (too far in looks); and Leo’s late cluster (L6–L7) stays separate (too far apart in time) until cross-time linking reaches it.
Node colour is each face’s true person; the dashed boxes are the tracklets the graph actually forms. A box that wraps two colours is a contaminated seed — exactly what the tight default cutoff is tuned to avoid. (Edge distances here are illustrative; the two cutoffs are the real constants, and k=10 never binds at this scale.)
“Whose face is this?” — BirthdayChildMatcher
We have time-local groups now — but a tracklet is still anonymous. This is where the birthday decides whose it is: scale the age model up from one face to a whole group. Two rules:
- pinned_child — for a single face, which enrolled child does its age uniquely point to? If the age is consistent with exactly one child, it “pins” that child. If zero children fit, or two fit (siblings too close in age that day), it pins nobody — we’d rather stay silent than guess.
- dominant_child — for a whole group, every face votes for the child it pins, and the plurality wins. Using a vote (not unanimity) means one mislabeled face can’t flip the answer. A tie for first place returns nobody.
A time-local group of faces (click to change observed bucket)
This single resolver is the shared brain of the algorithm: the seeding step uses it to label groups, and the cannot-link step uses it to keep siblings apart — so the two can never disagree about whose face is whose. Note one asymmetry that Part 3 builds on: the fast live matcher skips this birthday seeding entirely — the one-time backfill owns grouping quality, and live just places new faces against the identities it built. But live isn’t birthday-blind: it borrows the same birthday age model as a gate — before routing a face to the baby, it checks the face’s age bucket is consistent with the baby’s birthday.
The guardrails: cannot-link rules
The tracklet and linking steps are eager to merge anything that looks alike. The cannot-link constraints are the veto. Before any two groups merge, the algorithm asks three independent questions — if any says “these can’t be the same person”, the merge is blocked. Every rule fails open: if it can’t tell, it stays quiet rather than wrongly blocking.
Here are all three, in order. Each shows the case it’s built for — switch scenarios within a rule to watch it fire or stay silent, and read every rule’s verdict plus the composite constraint they OR into.
Below the montage threshold line the two faces in 📷101 are near-identical embeddings — the same person printed twice in one collage. Co-occurrence waives its veto and the merge is allowed.
Set A’s ages uniquely fit Mia and set B’s fit Leo — two different enrolled children, so birthday-anchored BLOCKS the merge. This is the case that defeated every earlier version.
2022-05
2022-05
2024-09
2024-09
Both sets resolve to Mia, so birthday-anchored stays silent and the same-child-exempt wrapper switches the age window off. Mia links freely across the years instead of self-splitting.
Part 3
Then it runs forever
The backfill ran once and stopped. From here, photos arrive one at a time — a different job, the same toolkit. Pick the story back up.
Live matching, start to finish
The backfill won the grouping quality once. The live matcher keeps the library sorted forever — every new upload placed on the spot, in about a second, against the identities the backfill built. It doesn’t re-cluster anything. Scroll on: the panel picks up exactly where the backfill left off, then a Tuesday-afternoon upload arrives.
Same family, same timeline — now the backfill's four groups are already in place, waiting for new faces.
Where the backfill left off
Four groups, sorted
Here’s the library the backfill handed over: Mia and Leo each one identity spanning years, the grandparent grouped but anonymous — plus the lone relative shot the reconciler set aside, anonymous too. Nothing re-runs. The job now is just to file new faces against these — fast, one photo at a time.
Live · 1 of 3
Tuesday, 4:12 pm — a new photo
Two faces land in the queue: a toddler, and an adult this library has never seen. Live matching doesn’t re-cluster anything — it just has to file these two against the identities the backfill built.
Live · 2 of 3
Placed in milliseconds
Each new face greedy-matches the closest existing identity by cosine distance, under an age-aware threshold. Leo’s centroid is recomputed anchored at this photo’s date — toddler-Leo is compared against toddler-era Leo, not an average of his babyhood. And the birthday still stands guard: a face only routes to the baby if its bucket is consistent with the baby’s birthday (±1 bucket). The toddler clears every check and joins Leo. The adult is close to nobody.
Live · 3 of 3
Strangers get a fresh start
A face that matches nothing isn’t discarded — it bootstraps a brand-new anonymous identity. That’s how the system meets people it has never seen. And that’s the loop: the backfill won the grouping quality once; live keeps the library sorted forever, one upload at a time. (If a family vanishes for two years and bulk-imports, a last-seen fallback bridges the gap — the results section has the numbers.)
Place a new face yourself
Live matching is deliberately simple — and that simplicity is the whole point of the results later. A new face greedy-matches its closest existing identity, and joins it only if it clears two gates at once: close enough in looks (under an age-aware cosine cap) and an age the birthday will vouch for. Either gate can say no — and when looks say yes but the birthday says no, the birthday wins. That single rule is what keeps an adult face out of the baby.
A clean match: the face looks like Leo and reads as a toddler, exactly the age his birthday predicts. Now click the face up to “adult” — the look distance never moves, but watch the birthday gate flip the verdict. (The cap is 0.45 because Leo is between 1 and 5; under age 1 it loosens to 0.55, after 5 it tightens to 0.40.)
That’s the whole live matcher in one decision. No tracklets, no birthday voting, no re-clustering — just nearest-identity, an age-aware cap, and the birthday gate. The backfill earned the hard-won grouping; live only has to preserve it. The next section shows why keeping live this simple turned out to be the right call.
Part 4
Does it hold up?
How we know any change actually helped — and the honest before/after, wins and regressions alike.
How far we’ve come
Let’s be honest up front: face matching for kids will never be 100%. Siblings really do look alike, a newborn really doesn’t look like the toddler they become, and some photos are just too dark or too sideways to place anyone. Perfection isn’t the goal — the goal is to keep dragging the error down, iteration after iteration, and to know exactly how much each change moved the needle.
Of all the numbers our test harness reports, the most tangible is the headline below: how many faces ended up filed under the wrong person? A face is miscategorized when it lands in an identity that mostly belongs to someone else — a sibling, the grandparent — or gets stranded away from where the child’s photos mostly live. We track it on both golden families: the easy single child (581 faces) and the hard two-sibling case with Mia, Leo and the grandparent (1,193 faces).
v1 — Per-photo cosine matching — the production baseline.
Greedy per-photo matching. Fine on one child, but on the hard family it both blends siblings and scatters each child — 30% land in the wrong identity.
Walk it end to end and the shape of the story shows up: v1 → v5 is 127 fewer faces in the wrong pile on the hard family — about a third of the original errors gone — but the road there was anything but a straight line. Here’s every iteration laid out as a table, on both families:
| Version | What changed | Single child wrong of 581 |
Two siblings wrong of 1,193 |
|---|---|---|---|
| v1 | Per-photo cosine matching — the production baseline | 95 · 16% | 354 · 30% |
| v2 | Batch multi-stage clusterer — k-NN tracklets + time-local linking | 47 · 8% | 631 · 53% |
| v3 | + co-occurrence cannot-link — two faces in one photo are two people | 42 · 7% | 385 · 32% |
| v4 | + age-bucket veto & birthday-aware seeding | 63 · 11% | 328 · 28% |
| v5 | Birthday-anchored seeding + constraint — where we landed | 59 · 10% | 227 · 19% |
Two things jump out. On the single-child family, just moving from per-photo matching to batch k-NN tracklets (v2) roughly halved the errors, and they’ve stayed low ever since. On the two-sibling family, v2 actually made things much worse — chasing recall, it happily fused the siblings into one blob — and it took the cannot-link constraints (v3–v4) to claw that back and the birthday anchor (v5) to finally beat the original baseline by a wide margin. Real progress, earned the hard way: the same change that’s a clear win on an easy family can be a regression on a hard one, which is exactly why we score every version on both.
And then we made the headline number worse — on purpose. Everything above chased one count down: fewer faces in the wrong pile. The most recent change breaks that streak deliberately. After clustering, the shipped backfill runs the correction pass from Part 1 — the age bucket reconciler — which ejects any face whose age bucket flatly contradicts the known age of the child it was filed under. Measured on the same two families (isolating the pass alone), it moves two numbers in opposite directions:
| Family | Aggregate wrong every misfiled face |
Inside an enrolled child intruders in a named identity |
False evictions |
|---|---|---|---|
| Single child | 59 → 61 (+2) | 9 → 7 (−2) | 0 / 4 |
| Two siblings | 227 → 244 (+17) | 82 → 63 (−19) | 0 / 43 |
The fine print: where we landed, and the upgrades since (for the curious)
The version numbers are gone. Once v5 won, we collapsed the experiment scaffolding into the two jobs that actually run — the backfill clusterer (the former v5) and plain closest-cosine live matching (the former v1). Live earned that spot in a head-to-head on the hard family, scored after the live stage: plain cosine hit F1 80.7, a cleverer clustering-style live path managed ~80.1 while paying an O(N²) family-wide pass on every upload, and a tracklet-based live experiment regressed to 72.2. When simple ties clever, simple wins. The periodic maintenance sweep met the same bar: built, validated on the lifecycle harness, never scheduled — and with backfill + live holding quality on their own, dropped rather than turned on.
Live has kept earning small upgrades since. Two mechanism fixes landed after the versions settled, both driven by the same lifecycle harness. First, the age-bucket gate (the one you drove in Part 3): live’s baby routing now checks each face’s bucket against the baby’s birthday (the same BirthdayAgeModel, ±1 bucket) before assuming it’s the baby — so an adult face in a baby-detected photo never lands on the child, while a 2-year-old’s correctly toddler-bucketed faces still do. Second, the last-seen fallback: when a family bulk-imports photos years after their last upload, the time-anchored centroid finds no reference face close enough in time and the child’s identity went invisible — every new face fragmented into anonymous identities. Now the matcher falls back to the child’s appearance as of their most recent photo, and the first face it bridges becomes a fresh anchor for the rest of the batch. On the hard family that lifted after-live F1 from 80.7 to 82.5 (recall 74.1 → 76.9, precision flat); the easy family was unchanged because it has no time gap — the fallback never fires. Two families is a directional signal, not proof — same caveat as always.
This round of work reached beyond the algorithm, too. The birthday anchor is only as good as the birthday behind it — so as part of v5 we updated onboarding to ask for each sibling’s birthday up front. That turns the anchor from something we could only hand-label on golden families into real data every family provides.
The face alone is never enough
Step back and the whole algorithm is one idea applied over and over: the face alone is never enough, so anchor it to the birthday. Every face gets a developmental-stage bucket, and the birthday age model reads that bucket against a known birthday with ±1 slack. Faces that sit close in both time and appearance get grouped into tracklets — connected components of a k-nearest-neighbour similarity graph — and each tracklet is tagged with the child its age fits, seeding high-confidence anchors. From there, linking grows each identity across the years while a cannot-link rule, gated by that same birthday anchor, keeps siblings and strangers out. One final backfill pass then ejects any face whose age flatly contradicts the child it was filed under — trading a sliver of aggregate neatness for cleaner named identities, on purpose. And once the backfill has built those identities, live matching keeps it running forever: every new upload is placed on the spot by closest-cosine against the existing identities, with the age model deciding which faces are even allowed to be the baby. Face matching for kids will never be 100% — but every one of these layers is a cheap, independent axis that makes it harder to get wrong. What the named identities are for is the next article: building meaningful moments out of a family’s photos →