Cleans dust, dirt, splatter and spots off product photos at full resolution — and leaves every other pixel exactly as it was.
$ pristine photo.jpg -o clean.jpg
photo.jpg 6000x4000 1,113 spots -> 409,444 px cleaned, 98.29% untouchedBecause it hands you back a different product.
Give a 24MP photo to any image model and you get a 1–4MP regeneration — it redraws your product from a description. Tested on a real 24MP product shot, a single full-frame pass:
- erased embossed lettering from a panel,
- redrew a bracket with a different number of slots,
- moved a logo several hundred pixels,
- and softened the whole frame.
It looked clean. It was also no longer a photograph of the thing being sold.
pristine works the other way around. It finds the dirt, repairs only the dirt, and never touches anything else. Typically 98% of the frame comes out byte-identical to your original — same sharpness, same framing, same colour, same lens character. Because it isn't regenerating the photo, it can't invent detail that was never there.
git clone https://github.com/ishaanpilar/pristine.git
cd pristine
python3 -m venv .venv
.venv/bin/pip install -r requirements.txtPython 3.10+. No GPU, no model downloads, no API key. Runs offline.
# 1. See what it WOULD remove, before changing anything
.venv/bin/python clean.py photo.jpg --dry-run --debug-dir dbg/
# 2. Clean it
.venv/bin/python clean.py photo.jpg -o clean.jpg
# 3. Or a whole shoot
.venv/bin/python clean.py shoot/*.jpg --out-dir cleaned/Always look at dbg/<name>_mask.jpg first. Red is what will be removed. If
your logo or serial number is red, raise --threshold. This one habit prevents
every bad outcome the tool is capable of.
1. Find the dirt. A multi-scale morphological top-hat keeps features that are brighter than their surroundings and smaller than a given size — which is what dust, splatter and speckle look like, and what a panel edge or a bolt head does not. Three filters then remove the lookalikes:
| Filter | Removes | Because |
|---|---|---|
| size | sensor noise, huge regions | dirt has a size range; shadows don't |
| shape | specular highlights | a highlight along an edge is a long thin streak; dirt is round |
| density | lettering, logos, fine texture | text is a crowd of strokes; dirt is isolated |
That last one matters more than it sounds. A letter in a serial number is small and bright — nothing about its size or shape tells it apart from a speck. What does is company. Measuring structure density in a window and refusing to touch crowded areas cut damage to fine lettering from 6% to 0.8% in testing.
2. Repair each spot locally. Every blob is inpainted on its own small crop, not the whole frame. That keeps a 24MP file at ~30s and keeps each fill sourced from the surface it belongs to.
3. Put the texture back. Classical inpainting fills a hole with a smooth colour gradient — which is exactly what reads as a smudge on a textured surface. So the smooth fill is used for tone only, and the surface's own high-frequency texture is copied back in from clean pixels a few px away. This is the frequency-separation move a retoucher makes by hand, and it's the difference between "cleaned" and "obviously retouched".
Try --texture 0 to see it: moulded graining collapses into a flat patch.
4. Composite through a feathered mask, so only dirt pixels are ever written.
Most photos need nothing. When they do, these are the knobs that matter:
| Flag | Default | What it does |
|---|---|---|
--threshold |
2.6 |
Lower removes more, and risks real detail. The main dial. |
--protect |
0.30 |
Guards crowded areas — lettering, logos, fine texture. 0 disables. |
--scales |
21,41,81,141,241 |
Sizes of dirt to look for, in px. Raise the top for large deposits. |
--max-area |
40000 |
Bigger than this isn't dirt, it's a shadow or a part. |
--polarity |
bright |
bright = light dirt on a dark product. Also dark, both. |
--max-aspect |
3.0 |
Drops thin streaks. Raise it if scratches are your target — scratches are thin too. |
--texture |
0.85 |
How much surface texture is restored over the fill. |
--grain |
0.4 |
Sensor grain re-injected so patches don't look plastic. |
Two combinations worth knowing:
# Textured surface, no lettering in frame -- clean harder
.venv/bin/python clean.py photo.jpg -o clean.jpg --protect 0
# Big caked-on deposits
.venv/bin/python clean.py photo.jpg -o clean.jpg \
--threshold 2.2 --scales 21,61,141,241,341 --max-area 1500008-bit and 16-bit in, same out. ICC profile and EXIF preserved.
Measured on a synthetic benchmark with ground-truth defect masks — 420 dust specks, 14 hairs and 5 scratches over a product with engraved branding, a fine serial line, a knurled dial and machined texture. Reproduce it yourself:
.venv/bin/python tests/make_fixtures.py
.venv/bin/python tests/evaluate.py| dust removed (scored on defect pixels) | 69% |
| collateral change on clean pixels | 0.32 / 255 levels |
| engraved branding sharpness | 100.0% of original |
| knurled dial sharpness | 100.0% of original |
On a real 24MP product photograph: 1,113 spots found, 1.71% of the frame rewritten, 98.29% byte-identical, ~30 seconds, product lettering and geometry unchanged.
Scoring happens on the ground-truth defect pixels, not frame-wide averages. Dirt covers ~2% of a frame, so a frame-wide average is swamped by noise and reports nonsense — an early version of this benchmark claimed the tool made images worse for exactly that reason.
Worth knowing before you trust it on a deadline.
- Large caked-on deposits survive. Past a certain size a top-hat detector
stops calling it dirt. Widening
--scalesand--max-areahelps. A learned detector is the real fix; that's the roadmap. --protecttrades cleaning for safety. On a textured surface with no lettering,--protect 0cleans noticeably more. On anything with a serial number, leave it on.- Scratches need
--max-aspectraised. By default thin streaks are spared, because that's how specular highlights are told apart from dirt. - It cleans photos, not products. If the item is genuinely filthy, washing it before the shoot beats any amount of software.
| File | |
|---|---|
clean.py |
the main tool — local, free, offline |
detect.py |
the detector, importable on its own |
gemini_clean.py |
optional: repairs with the Gemini API instead. Marginally better on heavy deposits, ~$2–4 and ~5 min per photo. Sends 1024px crops only, never the full frame, and validates every returned tile before use. |
polish.py |
optional: merges a separately-produced AI-cleaned image back onto your original without losing sharpness |
tests/ |
synthetic benchmark with ground-truth masks |
MIT — see LICENSE.
