Skip to content
← Clementine seriesCraft

Figuring out who's who across thousands of photos

Bill Reed · July 9, 2026

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.

01

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.

Backfill20 faces · 0 named
tod
tod
pre
pre
pre
pre
chi
neo
neo
inf
inf
tod
tod
tod
adu
adu
adu
adu
?
?
tod
adu
✕ cannot-link · birthday says no✕ evicted · age contradicts the birthday
Mia (older sibling)Leo (the baby)Grandparentfirst-time visitorunassigned

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.

02

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.

InteractiveEvery dot is one face from this family’s photo library, plotted in embedding space (squashed to 2-D; positions are illustrative — the ordering of the distances is the real failure). Distance is the only knob a naïve matcher has: faces closer than a chosen threshold are called the same person, and chains of matches become identities. Drag it and try to find one cutoff that files all 13 faces under the right three people.
Same-person threshold: 0.17 cosine distance
Leo · 4 faces
one “person”: Leo + Mia
Grandparent · 3 faces
2w
3m
5m
8m
20m
2y
3y
3½
4y
4½
60s
60s
60s
Leo — six photos, newborn → 2 yMia — his sister, 3–4½ yGrandparent — a different personan identity the matcher files (≥2 faces)
An identity now mixes the siblings, and Leo is still split across his photo gap. Two failures at once — 2 of 13 misfiled, and the blend is the one parents feel most.

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.

03

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.

InteractiveDrag a face’s age and watch which bucket it lands in — or click a bucket card to jump straight to it. The cards are equal-width so you can read them — but the true-scale bar below is honest about the spans: all of childhood is a sliver, and “adult” swallows most of the bar. The slider rides that same true scale, so dragging all the way through childhood barely nudges it off the left edge.
Face age: 2 yr · 730 days — or click a bucket above to jump
A face aged 2 yr is tagged toddler.

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.

04

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.

InteractiveSet a birthday, a photo date, and what the model “saw”. Watch consistent? decide.
Tolerance: ±1 bucket
Age at photo2 yr 3 mo
Expected buckettoddler
Observed buckettoddler
Bucket distance0 bucket(s)
consistent? → true. Observed “toddler” is within ±1 of expected “toddler”.
±tolerance window around expectedobserved inside → consistentobserved outside → not
05

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.

InteractiveBoth knobs at once. Leo is photographed every few weeks (one drifting child) with the grandparent wandering through. Drag the two cutoffs and watch edges form, tracklets merge and split, and the stranger leak in or stay out.
Looks cutoff (IN_WINDOW_MAX_DISTANCE): 0.32
Time window (WINDOW_DAYS): 30 days
tracklet · 5
tracklet · 2
L1
L2
L3
L4
L5
G1
L6
L7
Leo (one child, drifting)Grandparent (different face)tracklet (≥2 faces)
2 clean tracklets, each ≥2 faces and one person.

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.)

06

“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.
InteractiveTwo enrolled siblings. Click a face to cycle the bucket the model saw, and watch each face’s pin and the group’s vote update.
Mia
born 2020-03-15
Leo
born 2023-08-01

A time-local group of faces (click to change observed bucket)

Mia3
Leo0
Tracklet tagged → Mia. It becomes a high-confidence seed for Mia.

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.

07

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.

InteractiveTwo faces in the same photo are normally two different people, so their sets may not merge. The one exception: a montage (the same person appearing twice in one collage), detected when the two faces are extremely close in embedding space. Drag the distance below.
Co-occurrence
Face set A
📷 101
same frame
Face set B
📷 101
same frame
0.10 · montage line
Cross-pair embedding distance: 0.04
Co-occurrencesilent
Birthday-anchoredsilent
Same-child-exempt age windowsilent
Composite (OR) → may these merge?ALLOWED

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.

InteractiveReads each set’s faces against the children’s known birthdays. If the two sets resolve to two different enrolled children, they cannot be the same person. Graded ±1 bucket, plurality vote — so one bad bucket can’t cause a wrong block. Stays silent when age can’t tell the children apart. Step set B’s age and watch it resolve.
Birthday-anchored
Face set A
preschooler
2024-04
child
2024-04
resolves → Mia
Face set B
infant
2024-04
infant
2024-04
Step ageinfant
resolves → Leo
Co-occurrencesilent
Birthday-anchoredBLOCK
Same-child-exempt age windowBLOCK
Composite (OR) → may these merge?BLOCKED

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.

Interactivev4’s birthday-window age check, wrapped so it’s exempt whenever both sets resolve to the same child (preventing a self-split across eras) but still active against un-enrolled people — keeping relatives and strangers out of a child’s identity.
Same-child-exempt age window
Face set A
Mia
preschooler
2022-05
Mia
preschooler
2022-05
resolves → Mia
Face set B
Mia
child
2024-09
Mia
child
2024-09
resolves → Mia
Co-occurrencesilent
Birthday-anchoredsilent
Same-child-exempt age windowsilent
Composite (OR) → may these merge?ALLOWED

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.

08

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.

Live4 groups · ready for new photos
Mia · 7 faces
Leo · 7 faces
Grandparent · anonymous
evicted · anonymous
tod
tod
pre
pre
pre
pre
chi
neo
neo
inf
inf
tod
tod
tod
adu
adu
adu
adu
?
?
tod
adu
✕ cannot-link · birthday says no✕ evicted · age contradicts the birthday
Mia (older sibling)Leo (the baby)Grandparentfirst-time visitorunassigned

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.)

09

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.

InteractiveA new photo’s face, and the closest identity it could join — Leo, the baby. Drag how close it is in looks, and click the face to cycle the bucket the model saw. Watch both gates, then the final routing.
closest identity
Leo · the baby
born 2024-04-01
expected on 2025-09-15: toddler
Look distance to Leo (cosine): 0.30
Looks0.30 ≤ 0.45 capclose enough
Birthday gatetoddler is 0 buckets from toddlerwithin ±1 — age fits the baby
Routed to Leo. Both gates pass, so the face joins his identity.

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.

10

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).

InteractiveStep through every version and watch the error move on both families at once. The counts are the real harness numbers; the identity grid is a schematic of the kind of mistake each version makes.

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.

Single child · 581 faces16%95 of 581 faces filed under the wrong person
Two siblings + grandparent · 1,193 faces30%354 of 1,193 faces filed under the wrong person
Hard family · how identities cluster (schematic)

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 →

Like how we work?

We're hiring engineers to build for millions of growing families.

See open roles