●SHUTDOWN — gemini-robotics-er-1.6-preview retires today, August 31. Any code still pointing at that preview model stops working from here on●DEPRECATION — September 30 is the next date to watch: the gemini-omni-flash-preview endpoint goes away and needs swapping for gemini-omni-1.1-flash, which reached GA on August 27●VIDEO — The Omni 1.1 Flash GA adds video extension through the extend task, and interpolation by passing two images to image_to_video so you can fix the first and last frame up front●VIDEO — A resolution parameter in video_config now accepts 360p, 720p as the default, 1080p, and 4k, with the note that 1080p and 4K outputs are produced by upscaling●SPEECH — gemini-3.5-transcribe reached GA on August 26 with utterance-level language detection across 85+ languages, speaker diarization, word-level timestamps, and up to 1,000 custom vocabulary terms●SPEECH — gemini-3.5-transcribe-live streams both ways over WebSockets on the Live API, with interim and finalized transcription events, a Smart transcription mode, and several VAD settings●SHUTDOWN — gemini-robotics-er-1.6-preview retires today, August 31. Any code still pointing at that preview model stops working from here on●DEPRECATION — September 30 is the next date to watch: the gemini-omni-flash-preview endpoint goes away and needs swapping for gemini-omni-1.1-flash, which reached GA on August 27●VIDEO — The Omni 1.1 Flash GA adds video extension through the extend task, and interpolation by passing two images to image_to_video so you can fix the first and last frame up front●VIDEO — A resolution parameter in video_config now accepts 360p, 720p as the default, 1080p, and 4k, with the note that 1080p and 4K outputs are produced by upscaling●SPEECH — gemini-3.5-transcribe reached GA on August 26 with utterance-level language detection across 85+ languages, speaker diarization, word-level timestamps, and up to 1,000 custom vocabulary terms●SPEECH — gemini-3.5-transcribe-live streams both ways over WebSockets on the Live API, with interim and finalized transcription events, a Smart transcription mode, and several VAD settings
Define What Counts as a Duplicate Before Adding Embedding Search to Your Image Pipeline
My dedup check flagged two prints with identical composition but different colors as one image. Here is how I split duplicate detection into three layers, measured where perceptual hashing ends, and decided what embedding search is actually for.
The wallpaper apps I run as an indie developer include a curated ukiyo-e collection, and every new asset passes through a duplicate check before it ships. One day I tried to register two prints in a row: the same composition, pulled from the same woodblock, but printed in completely different color palettes. The check declared them identical.
To me, they are two different works. Same block, different printing — I want both in the collection. That disagreement between the machine's answer and mine is what forced me to rethink how the check was built.
The timing was interesting, because File Search had just gained multimodal search, and gemini-embedding-2 can now embed images directly. Should I throw away hash matching and move the whole check to embedding search? I went back and forth for a while. The short answer: I adopted embeddings, but refused to make them the gatekeeper. This is the reasoning, with the actual distance numbers that shaped it.
The Conclusion First: Hashes Guard the Gate, Embeddings Only Suggest
Here is the structure I landed on.
Stage
Method
What the machine may do
At registration, first gate
SHA-256 (byte identity)
Reject automatically
At registration, second gate
dHash (64-bit perceptual hash)
Hold and queue for review. Never delete
After registration, async
Embedding nearest-neighbor search
Display similar existing works. Nothing else
Notice that I did adopt embedding search — I just never granted it the power to reject. The reason is the direction of each method's errors. A hash fails by missing a transformed copy. An embedding fails by calling a distinct work a duplicate. The first failure leaves one redundant pair in your collection. The second one deletes an asset you can never get back. Deletion is irreversible, so the method whose errors point in the dangerous direction does not get to stand at the gate. That is the whole principle.
"Duplicate" Is Not One Thing: Three Layers
Midway through the redesign I realized the real problem: I had never written down what I meant by duplicate. When I finally did, it split cleanly into three layers.
Layer
Meaning
Example
Who can answer
Layer 1
Identical file
The same file registered twice
SHA-256, instantly
Layer 2
Transformed copies of one image
Re-encodes, resizes, light crops
Perceptual hash, almost instantly
Layer 3
Distinct works that look alike
Alternate printings, series pieces, similar compositions
No algorithm. This is an editorial decision
My two prints lived in layer 3, and layer 3 is not a detection-accuracy problem at all. Whether an alternate printing of the same woodblock counts as one work or two is a question about how you want to present your collection. I had been trying to solve a curation question with a similarity metric, and that mismatch was the entire source of my confusion.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to define what counts as a duplicate in three distinct layers, and match each layer to perceptual hashing, embedding search, or human judgment before writing any code
✦You will be able to reproduce the boundary where a 64-bit hash stops working — the gap between distance 8 and distance 29 — and set thresholds for your own collection with evidence behind them
✦You will be able to prevent the irreversible mistake of auto-deleting distinct works that merely look similar, by deciding in advance which layer machines are allowed to act on
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
How much of layer 2 can a hash really cover? That is testable in an afternoon. I generated a structured test image, applied the transformations that actually occur in my pipeline, and measured dHash Hamming distances. This is the exact code I used.
from PIL import Imagedef dhash(img, size=8): # Shrink to 9x8 grayscale, encode neighbor brightness gradients into 64 bits g = img.convert("L").resize((size + 1, size), Image.LANCZOS) px = g.load() bits = 0 for y in range(size): for x in range(size): bits = (bits << 1) | (1 if px[x, y] > px[x + 1, y] else 0) return bitsdef hamming(a, b): # 0 = identical. Closer to 64 = unrelated return bin(a ^ b).count("1")# Usagea = dhash(Image.open("original.png"))b = dhash(Image.open("candidate.jpg"))print(hamming(a, b)) # Expected: single digits for transforms, 20+ for unrelated images
The measurements:
Transformation
Hamming distance (/64)
Verdict at threshold 10
JPEG re-encode (quality 40)
2
Flagged as duplicate
50% resize
0
Flagged as duplicate
5% edge crop
8
Flagged as duplicate
Brightness +30%
2
Flagged as duplicate
Stretched to 16:9
0
Flagged as duplicate
Color channels swapped
3
Flagged as duplicate
Bottom 25% cropped away
29
Slips through
A completely different image
28
Passes (correctly)
Everything typical of layer 2 — re-encodes, resizes, light crops — lands at single-digit distances. Because the hash normalizes size before comparing, resizes and even aspect-ratio stretches come back at distance 0. As a layer 2 gatekeeper, dHash earns its position.
No Threshold Separates 29 From 28
Two results contradicted my expectations, and they carry the article.
First: cropping away the bottom 25% produced a distance of 29 — statistically indistinguishable from the completely unrelated image at 28. Heavy crops look like different images to a hash. You might think raising the threshold to 30 would catch them, but at that point unrelated images start getting flagged too. There is a boundary between 29 and 28 that no threshold tuning can cross. If you need to catch heavily cropped copies, you need a semantic eye — which is exactly where embeddings belong.
Second: swapping the color channels wholesale moved the distance by only 3. dHash reads grayscale gradients, so identical composition means identical hash, regardless of palette. My two alternate printings were flagged as one image by precisely this property.
And here is the part worth sitting with. Had I switched the gate to embedding search, those two prints would have scored highly similar as well — they are close both visually and semantically. For layer 3, hashes and embeddings return the same answer. Neither technology would have resolved the disagreement I started with. What looked like a technology-selection problem was a definition problem wearing a disguise. That realization was the single most useful output of this whole exercise.
What Changed on the File Search Side
What convinced me to adopt embeddings in the suggest-only role was this year's sequence of updates. According to the Gemini API release notes, the multimodal embedding model gemini-embedding-2 entered preview on March 10, 2026, went GA on April 22, and on May 5 File Search gained native image embedding through that model. No more OCR detour — images go into the index as images.
You could build your own vector store and nearest-neighbor search, but for a solo-maintained collection in the tens of thousands, letting File Search own the index is easier to keep alive. I covered the search-side experience in Letting File Search's Multimodal Mode Find Wallpapers I Couldn't; this project simply repurposes that same index to surface duplicate suspects.
One operational note. Every embedded image costs an API call, and embedding models retire on Google's schedule, not yours. A local dHash has no sunset date: at a scale of 100,000 assets the comparison still runs entirely on my machine at an API cost of exactly zero. For a gate that every single asset must pass through, I prefer the component that cannot be shut down, repriced, or re-versioned underneath me. When the embedding side does turn over, the playbook from The Day You Switch Gemini Embedding Models: Designing a Zero-Downtime Reindex applies as-is.
The Final Shape: Machines Hold, Humans Decide
The registration flow settled into the code below. The critical detail is that a dHash hit never deletes anything — it queues the candidate with its evidence attached.
import hashlibimport jsonimport timefrom pathlib import Pathfrom PIL import ImageDHASH_THRESHOLD = 10 # My layer 2 transforms all measured at distance 8 or lessdef sha256_of(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest()def check_new_image(path, index): """index: dict of existing assets, {id: {"sha": ..., "dhash": ...}}""" sha = sha256_of(path) h = dhash(Image.open(path)) for asset_id, entry in index.items(): if entry["sha"] == sha: return {"action": "reject", "reason": f"identical file: {asset_id}"} suspects = [] for asset_id, entry in index.items(): d = hamming(h, entry["dhash"]) if d <= DHASH_THRESHOLD: suspects.append({"asset_id": asset_id, "distance": d}) if suspects: # Never delete here. Queue it with evidence for a human queue_item = { "path": str(path), "suspects": sorted(suspects, key=lambda s: s["distance"]), "queued_at": int(time.time()), } Path("review_queue.jsonl").open("a").write( json.dumps(queue_item, ensure_ascii=False) + "\n" ) return {"action": "hold", "suspects": len(suspects)} return {"action": "accept", "sha": sha, "dhash": h}
The review screen for that queue also shows the top five nearest neighbors from File Search. Once you state plainly that embeddings return "similar things" rather than "duplicates," their false positives stop being a problem — series pieces and same-subject works clustering together actually makes the layer 3 editorial call easier, not harder.
It echoes a lesson from when I expanded my category taxonomy and had to bound the blast radius of reclassification (The Day We Went From 30 Categories to 34): the scope of irreversible actions you delegate to a machine should be derived from the damage of its worst mistake, not from its average accuracy.
Generalizing the Decision
If you are weighing a dedup check for your own collection, or wondering whether embedding search should join it, the decision compresses into this table.
Dimension
Perceptual hash (dHash etc.)
Embedding search (gemini-embedding-2)
Strong layer
Layer 2: re-encodes, resizes, light crops
Toward layer 3: heavy crops, semantic closeness
Error direction
Misses transforms (safe side)
Calls distinct works close (dangerous if wired to delete)
Cost
Fully local, zero API spend
One API call per image
Lifespan
Algorithms have no sunset date
Subject to model retirement cycles
Appropriate authority
Up to automatic hold
Suggest and rank only
The order matters:
Write down your layer 3 edge cases and settle the definition first.
Measure layer 2 thresholds by applying real transformations to your own material.
Only then add embedding search, strictly as a suggestion layer.
Done in that order, the check is useful at every intermediate stage. Done in reverse — embeddings first, definition never — you end up staring at a ranked list of high similarity scores while nobody can answer the only question that matters: which of these am I allowed to delete?
Start by Writing the Definition Down
The next step I would suggest costs nothing: before writing any code, list three real pairs from your own collection where you have genuinely hesitated over whether something was a duplicate. Decide which of the three layers each pair belongs to. The thresholds, the placement of embedding search, and the authority you grant the machine will all follow from those three decisions. I put the definition off until two alternate printings stopped me at the gate; if this writeup spares you that detour, it has done its job.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.