Match delivered JPEGs back to their source raws, and star the raws — for re-editing an old shoot when the Lightroom catalog is gone.
You have a folder of raws and a folder of the finals you delivered years ago. You want to
re-edit, but the .lrcat is lost, so nothing tells you which 800 of the 2,400 raws you
actually picked. visual-recull works it out and writes an XMP sidecar next to each matched
raw, so Lightroom shows your original selects as starred on import.
It matches in two stages, cheapest first:
- Capture time. If the finals kept their EXIF capture time, each one is joined to its raw by timestamp — exact, and about 400× faster than pixel matching. A constant clock offset from a second camera body is discovered and corrected automatically.
- Pixels. Any final whose metadata was stripped falls through to content matching against the raw's embedded preview, which survives exposure, white balance, black-and- white, and heavy crops.
Both run by default; you do not choose. A shoot with intact metadata finishes in seconds; a fully scrubbed one uses pixels for everything; a mixed shoot handles each file the cheapest way it can.
python3 recull.py --root '/path/to/job' --set final:1 --set blog:2 --applyThat reads /path/to/job/raw, matches everything in final/ and blog/, and writes
<raw>.xmp carrying xmp:Rating — 1 star for finals, 2 for blog picks. Raws are never
opened for writing; undo is rm raw/*.xmp.
One job folder, with the raws in a subfolder and each delivered set in its own subfolder:
/path/to/job/ <- pass this as --root
├── raw/ <- every raw from the shoot (--raw, default "raw")
│ ├── DSC_4471.NEF
│ ├── DSC_4472.NEF
│ └── ...
├── final/ <- delivered JPEGs --set final:1
│ ├── smith_0001.jpg
│ └── ...
└── blog/ <- highlights, a subset --set blog:2
├── smith-wedding-01.jpg
└── ...
The subfolder names are yours — --set takes whatever you call them, and you can pass it
as many times as you have sets. --set portfolio:3 would add a third tier.
Every folder is read flat. Nested subfolders are not searched, so if your raws are
split across raw/card1/ and raw/card2/, flatten them first.
Filenames need no relationship to each other — matching is on pixels alone. The delivered sets may overlap freely; when two sets claim the same raw, the highest rating wins.
After --apply, sidecars appear beside the raws they belong to:
raw/
├── DSC_4471.NEF
├── DSC_4471.xmp <- created: xmp:Rating=1
├── DSC_4472.NEF <- unmatched, left alone, stays 0 stars
└── ...
Point Lightroom at raw/ and import. Sidecars are read automatically on import; if the
folder is already in your catalog, use Metadata → Read Metadata from File.
Two files are also written to the job root: match_report.csv (every decision, with
scores and runners-up) and, with --contact-sheet, an HTML review page.
When the finals kept their EXIF capture time, this is the whole job: read the timestamp off each final and each raw and join them. Modern cameras stamp the time to the sub-second, so even a rapid burst gives every frame a distinct key — on a real 2,354-raw shoot every timestamp was unique, and 429 delivered files each matched exactly one raw in about 40 seconds, all of it just reading the raws.
If a camera's clock was wrong — a second body set to the wrong hour, a timezone never
corrected, a Lightroom "Edit Capture Time" shift — its finals are all off from their raws by
the same amount. visual-recull finds that offset automatically: it histograms every
final-to-raw time delta, and any offset shared by a block of files is a real camera. Each
detected offset is reported with the serial number it belongs to:
[final] metadata matched 812/812 on capture time
offset 0 (clocks aligned): 573 files (serial 06138106)
offset -3600.000s: 239 files (serial 04521199)
This makes no assumption that a shift preserves the sub-second fraction — whether Lightroom kept it or moved it uniformly, a constant offset is still constant and still found. The only case it cannot resolve is a per-frame change to the sub-second value, which leaves no constant offset; those files fall through to pixel matching, so the result stays correct either way.
When metadata is gone, every cheaper option fails on a typical delivery:
- Filenames are renamed on export (
DSC_4471.NEF→smith_0248.jpg). - Capture time, serial, shutter count are gone. A "copyright only" export strips them, and optimizers like JPEGmini strip whatever survives.
xmpMM:OriginalDocumentIDlooks like the answer — it's on every Lightroom export, it's 32 hex characters, and it looks like an MD5 of the source. It is not. Tested against a known-correct pair, it matches none of the raw's whole-file MD5, first-1MB MD5, first-64KB MD5, embedded-preview MD5,JpgFromRawMD5, or truncated SHA-1. It is generated by the catalog, so without the catalog it is unrecoverable.
So content is the only link, and it has to survive heavy edits: exposure and white balance shifts, black-and-white conversion, retouching, and crops down to half the frame.
For finals with no usable capture time, matching is done against each raw's embedded JPEG preview, not the raw data — exiftool seeks straight to the preview offset, so indexing 2,400 raws takes about 30 seconds and needs no raw decoder.
Descriptors are grayscale, mean-centered and unit-normalized, which makes every score a normalized cross-correlation: invariant to exposure and contrast, and blind to color, so a black-and-white conversion still matches its source.
Four tiers, each only paying for what the previous one couldn't settle:
- Whole-frame pass over all pairs — one matmul. Settles most files outright.
- Sequence anchoring. Export numbering is usually chronological, so confident matches should land in increasing capture order. A longest-increasing-subsequence pass keeps the consistent ones as anchors. This is a soft preference used to break ties, never a filter — an out-of-order export degrades the result instead of cascading.
- Crop search for whatever is left, sweeping every raw. The final is a crop of the raw, so candidate windows are sampled at a range of scales and offsets. This tier deliberately does not trust the tier-1 shortlist: that shortlist is built from the same whole-frame descriptor a heavy crop defeats, so the true raw is often missing from it.
- Full-resolution re-rank of the finalists against the full-size embedded JPEG.
Two details that silently wreck a naive implementation:
- Orientation. A vertical capture has landscape preview pixels plus an
Orientationtag, while the delivered JPEG has the rotation baked in and no tag. Rotate the preview by its tag or those files score as noise. - Truncation, not file size. To detect a raw that is still copying, check that its
image strips fit inside the file (
StripOffsets + StripByteCounts <= FileSize). File size is useless here: compressed raws from one body vary ~30% with scene content, so any threshold both passes half-copied files and rejects perfectly good small ones.
Nothing below --min-score (default 0.75) is starred — a wrong star is worse than none,
since it also leaves the right frame unstarred. Those are listed at the end of the run and
in match_report.csv.
# rebuild the review page from an existing report, no re-matching
python3 recull.py --root '/path/to/job' --set final:1 --sheet-only \
--contact-sheet review.html --sheet-height 700review.html pairs each delivered file with its matched raw, worst confidence first.
The honest limitation is burst frames. Adjacent frames are near-identical, so a high score confirms the scene but not necessarily the exact frame. If missing the right raw costs you more than culling an extra one, widen the selection:
python3 recull.py --root '/path/to/job' --set final:1 --expand-ties 0.01 --applyThat also stars any runner-up within 0.01 of the winner — statistically indistinguishable,
which is exactly where a wrong pick is likely. Speculative additions get xmp:Label=Yellow
so you can filter and prune them in Lightroom instead of losing them among the confident
matches. It works from the existing report, so it runs in about a second.
Note it only ever adds. To genuinely shrink a selection, delete the sidecars and re-expand from the report.
Capture-time stage. On a 2,354-raw shoot with 429 delivered files that kept their EXIF timestamps: 429/429, exact, no review needed. With 25 of those finals artificially shifted by an hour to simulate a mis-set second body, the offset was detected and all 60 in that test still matched the correct raw.
Pixel stage. On a 2,435-raw wedding with 862 delivered files, all metadata stripped: 848 matched, 14 below threshold. Every one of those 14 was verified by eye to be the correct scene with its raw present — they scored low because of heavy crops and dark or black-and-white conversions, not because anything was missing.
Against 174 pairs established by an independent method: 174/174.
On a deliberately hostile synthetic set — random crops to 55% of frame area, portrait crops out of landscape frames, black-and-white conversions, brightness and contrast jitter, and capture order destroyed — 25/36, with every remaining error an adjacent burst frame. Real deliveries keep the ordering signal that test removes.
exiftool on PATH, and Python 3 with pillow and numpy. No raw decoder, no OpenCV.
brew install exiftool && pip3 install pillow numpy| Flag | Purpose |
|---|---|
--set DIR:STARS |
A delivered folder and its rating. Repeatable. Highest rating wins if two sets claim one raw. |
--apply |
Write sidecars. Default is a dry run. |
--no-metadata |
Skip capture-time matching and go straight to pixels. On by default and tried first. |
--offset-min-support |
A clock offset must align at least this many files to be trusted as a camera shift (default: 8). |
--min-score |
Below this, leave unmatched rather than guess (default 0.75). Pixel stage only. |
--expand-ties DELTA |
Also star runners-up within DELTA of the winner, labeled. |
--only SUBSTR |
Restrict to matching filenames; all raws are still searched. |
--contact-sheet / --sheet-only |
Build or rebuild the HTML review page. |
--allow-truncated |
Index raws whose data runs past EOF instead of skipping. |
--rotations |
Also try raws at 90/180/270. Only needed if Orientation tags are wrong; costs 4x. |
Raw formats are matched by extension (NEF, CR2, CR3, ARW, RAF, RW2, ORF,
DNG, PEF, SRW). Only NEF has been tested on a real job — the others should work,
since matching only needs an embedded JPEG preview and an Orientation tag, but they are
untested. Reports welcome.
MIT