photofresh - adaptive photo touch-up for phone photos A developer released photofresh, an open-source Python script that watches an inbox folder and applies adaptive, per-photo touch-ups to phone JPEGs before writing them into year/month subfolders for an Immich external library. The tool measures each image first — white balance cast, histogram compression, midtone darkness, noise sigma in the flattest blocks — and only then applies corrections such as half-strength color-cast correction, CLAHE local contrast mixed at 40%, and flat-area-only noise reduction, preserving all EXIF data unchanged. A "night mode" keeps high-ISO dark shots dark and cleans up noise only. | | photofresh.py - adaptive, per-photo touch-up for phone photos no AI, nothing invented | | | | | | Watches an inbox folder, gently improves each new JPEG and writes the result to an | | | output folder e.g. an Immich external library , sorted into year/month subfolders. | | | | | | Folders inside the container : | | | /data/inbox <- new photos arrive here e.g. via Syncthing | | | /data/processed <- originals after processing auto-deleted after KEEP DAYS | | | /data/failed <- photos that could not be processed never deleted | | | /data/redo <- put an original here to process it again and overwrite the old result | | | /output <- processed photos, in year/month subfolders | | | | | | Per photo it MEASURES first and only then adjusts: | | | 1. white balance: corrects half of the measured colour cast max 12% | | | 2. contrast range: stretched only if the histogram is compressed haze, flat light | | | 3. midtone lift: only for photos that are too dark | | | 4. local contrast CLAHE , mixed in at 40% | | | 5. noise reduction: only if measured noise is high, and only on flat areas walls, sky | | | "Night mode": high ISO + dark photo - keep the darkness, only clean up noise. | | | All EXIF data date, GPS, orientation is copied unchanged. | | | import os, time, shutil, subprocess, traceback | | | import cv2, numpy as np | | | | | | DATA = "/data" | | | INBOX, PROCESSED, FAILED, REDO = f"{DATA}/{d}" for d in "inbox", "processed", "failed", "redo" | | | OUTPUT = "/output" | | | LEDGER, LOG = f"{DATA}/processed.list", f"{DATA}/photofresh.log" | | | KEEP DAYS = float os.environ.get "KEEP DAYS", "30" 0 = delete originals right after processing | | | SHARPEN = float os.environ.get "SHARPEN", "0" 0 = off default , 1 = light sharpening | | | NIGHT ISO = float os.environ.get "NIGHT ISO", "1000" ISO from which a dark photo counts as a night shot 0 = off | | | PUID, PGID = int os.environ.get "PUID", "99" , int os.environ.get "PGID", "100" 99/100 = Unraid default | | | JPEG QUALITY = int os.environ.get "JPEG QUALITY", "95" | | | INTERVAL = 120 seconds between scans | | | MIN AGE = 30 a file must be unchanged for this long still syncing otherwise | | | EXT = ".jpg", ".jpeg" | | | | | | def log msg : | | | line = time.strftime "%Y-%m-%d %H:%M:%S " + msg | | | print line, flush=True | | | with open LOG, "a" as f: f.write line + "\n" | | | | | | def set owner path, is dir=False : | | | try: | | | os.chown path, PUID, PGID | | | os.chmod path, 0o777 if is dir else 0o666 | | | except Exception: pass | | | | | | def make dir path : | | | if not os.path.isdir path : | | | os.makedirs path, exist ok=True | | | set owner path, True | | | | | | ---------- the algorithm ---------- | | | def noise sigma gray : | | | """Estimate noise only in the 15% flattest blocks, so texture grass, hair doesn't count as noise.""" | | | g = gray.astype np.float32 | | | hp = g - cv2.GaussianBlur g, 0, 0 , 1.5 | | | grad = cv2.GaussianBlur np.abs cv2.Sobel cv2.GaussianBlur g, 0, 0 , 3 , cv2.CV 32F, 1, 1 , 0, 0 , 3 | | | B = 32; vals = ; flat = | | | for y in range 0, g.shape 0 - B, B : | | | for x in range 0, g.shape 1 - B, B : | | | m = g y:y+B, x:x+B .mean | | | if 25 < m < 235: | | | flat.append grad y:y+B, x:x+B .mean | | | vals.append np.median np.abs hp y:y+B, x:x+B 1.4826 | | | if not vals: return 0.0 | | | flat = np.array flat ; vals = np.array vals | | | return float np.median vals flat <= np.percentile flat, 15 | | | | | | def enhance bgr, iso=None : | | | info = {} | | | img = bgr.astype np.float32 / 255.0 | | | night shot? high ISO and dark image : keep the darkness, it is the mood | | | mean = float np.mean cv2.cvtColor bgr, cv2.COLOR BGR2GRAY / 255 | | | night = bool NIGHT ISO 0 and iso is not None and iso = NIGHT ISO and mean < 0.40 | | | info "night" = night | | | 1. white balance: measure the cast on near-grey pixels, correct 50% 25% at night , max 12% | | | hsv = cv2.cvtColor bgr, cv2.COLOR BGR2HSV | | | v, s = hsv ..., 2 , hsv ..., 1 | | | mask = s < 60 & v 40 & v < 240 | | | if mask.sum < 0.02 mask.size: mask = v 20 & v < 245 | | | means = np.array img ..., c mask .mean for c in range 3 | | | strength = 0.25 if night else 0.5 | | | gains = np.clip 1 + strength means.mean / means - 1 , 0.88, 1.12 | | | img = np.clip img gains, 0, 1 | | | info "wb" = round float x , 3 for x in gains | | | 2. stretch the contrast range may clip a tiny bit | | | lab = cv2.cvtColor img 255 .astype np.uint8 , cv2.COLOR BGR2LAB | | | L = lab ..., 0 .astype np.float32 | | | lo, hi = np.percentile L, 0.5 , np.percentile L, 99.5 | | | lo new = lo - 0.7 max lo - 6, 0 | | | hi new = hi + 0.8 max 252 - hi, 0 | | | if hi - lo 10 and not night: | | | L = L - lo / hi - lo hi new - lo new + lo new | | | 3. photo too dark: gentle midtone lift | | | meanL = float np.mean np.clip L, 0, 255 / 255 | | | if meanL < 0.42 and not night: | | | gamma = max 0.85, np.log 0.45 / np.log max meanL, 1e-3 | | | L = 255 np.power np.clip L / 255, 0, 1 , gamma | | | 4. adaptive local contrast CLAHE , mixed in at 40%; skipped at night it lifts shadows and grain | | | L8 = np.clip L, 0, 255 .astype np.uint8 | | | if not night: | | | L8 = cv2.addWeighted L8, 0.6, cv2.createCLAHE clipLimit=1.6, tileGridSize= 8, 8 .apply L8 , 0.4, 0 | | | lab ..., 0 = L8 | | | out = cv2.cvtColor lab, cv2.COLOR LAB2BGR | | | 5. noise reduction only when needed, and only on flat areas | | | sig = noise sigma cv2.cvtColor bgr, cv2.COLOR BGR2GRAY | | | info "noise" = round sig, 2 | | | s ref = max sig, 0.8 | | | g = cv2.cvtColor out, cv2.COLOR BGR2GRAY .astype np.float32 | | | tex = cv2.GaussianBlur np.abs g - cv2.GaussianBlur g, 0, 0 , 2 , 0, 0 , 4 | | | texture = np.clip tex - s ref / 3.0 s ref , 0, 1 0 = flat, 1 = texture/edge | | | if sig 1.2: | | | luminance noise grain : gentle; colour noise speckles : firm | | | h lum = float np.clip sig - 1.2 0.8 + 2.5, 2.5, 5.0 | | | h col = float np.clip sig - 1.2 2.0 + 6.0, 6.0, 14.0 | | | den = cv2.fastNlMeansDenoisingColored out, None, h lum, h col, 5, 15 | | | w = 1 - texture ..., None | | | out = den w + out 1 - w .astype np.uint8 | | | 6. optional light sharpening OFF by default : luminance only, textured areas only, | | | ignores differences below the noise level, clamped to avoid halos | | | if SHARPEN 0: | | | lab = cv2.cvtColor out, cv2.COLOR BGR2LAB | | | Lf = lab ..., 0 .astype np.float32 | | | detail = Lf - cv2.GaussianBlur Lf, 0, 0 , 1.0 | | | thr = max 1.5, 1.2 sig | | | detail = np.sign detail np.maximum np.abs detail - thr, 0 | | | detail = np.clip detail, -12, 12 | | | amount = 0.7 if sig < 3 else 0.5 SHARPEN | | | lab ..., 0 = np.clip Lf + amount detail texture, 0, 255 .astype np.uint8 | | | out = cv2.cvtColor lab, cv2.COLOR LAB2BGR | | | return out, info | | | | | | ---------- file handling ---------- | | | def load ledger : | | | if not os.path.exists LEDGER : return set | | | with open LEDGER as f: return set l.strip for l in f if l.strip | | | | | | def read iso src : | | | try: | | | r = subprocess.run "exiftool", "-s3", "-n", "-ISO", src , capture output=True, text=True, timeout=30 | | | return float r.stdout.strip .split 0 | | | except Exception: | | | return None | | | | | | def target subdir src : | | | try: | | | r = subprocess.run "exiftool", "-s3", "-d", "%Y/%m", "-DateTimeOriginal", src , | | | capture output=True, text=True, timeout=30 | | | sub = r.stdout.strip | | | if len sub == 7 and sub 4 == "/": return sub | | | except Exception: pass | | | return time.strftime "%Y/%m", time.localtime os.path.getmtime src | | | | | | def unique path : | | | if not os.path.exists path : return path | | | base, ext = os.path.splitext path ; i = 1 | | | while os.path.exists f"{base} {i}{ext}" : i += 1 | | | return f"{base} {i}{ext}" | | | | | | def process name, ledger, folder=INBOX, redo=False : | | | src = os.path.join folder, name | | | key = f"{name}\|{os.path.getsize src }" | | | if key in ledger and not redo: | | | already done e.g. re-synced : don't duplicate it in the library, but don't delete it either | | | shutil.move src, unique os.path.join PROCESSED, name | | | log f"skipped already processed , moved to 'processed': {name} - put it in 'redo' to process it again" ; return | | | IGNORE ORIENTATION: don't rotate pixels, so the original Orientation tag stays correct | | | bgr = cv2.imread src, cv2.IMREAD COLOR \| cv2.IMREAD IGNORE ORIENTATION | | | if bgr is None: raise RuntimeError "could not read photo" | | | iso = read iso src | | | out, info = enhance bgr, iso | | | sub = target subdir src | | | make dir os.path.join OUTPUT, sub :4 ; make dir os.path.join OUTPUT, sub | | | dst = os.path.join OUTPUT, sub, name | | | if not redo: dst = unique dst in 'redo' the old result is overwritten | | | tmp = dst + ".tmp.jpg" | | | if not cv2.imwrite tmp, out, cv2.IMWRITE JPEG QUALITY, JPEG QUALITY : raise RuntimeError "write failed" | | | r = subprocess.run "exiftool", "-q", "-overwrite original", "-TagsFromFile", src, "-all:all", tmp , | | | capture output=True, text=True, timeout=60 | | | if r.returncode = 0: raise RuntimeError "copying metadata failed: " + r.stderr.strip | | | if cv2.imread tmp is None: raise RuntimeError "result check failed" | | | if not redo: in 'redo' the file gets the current time so the library notices the change | | | st = os.stat src ; os.utime tmp, st.st atime, st.st mtime | | | os.rename tmp, dst ; set owner dst | | | if key not in ledger: | | | with open LEDGER, "a" as f: f.write key + "\n" | | | ledger.add key | | | if KEEP DAYS 0: | | | keep = unique os.path.join PROCESSED, name | | | shutil.move src, keep | | | os.utime keep, None retention clock starts now, not at the capture date | | | else: | | | os.remove src | | | log f"{'redo ' if redo else ''}done: {name} - {sub}/ iso={iso} night={'yes' if info 'night' else 'no'} wb={info 'wb' } noise={info 'noise' }" | | | | | | def cleanup : | | | limit = time.time - KEEP DAYS 86400 | | | for n in os.listdir PROCESSED : | | | p = os.path.join PROCESSED, n | | | if os.path.isfile p and os.path.getmtime p < limit: | | | os.remove p ; log f"original deleted after {KEEP DAYS:g} days: {n}" | | | | | | REPORTED = set so each skipped file is logged only once | | | | | | def report once name, reason : | | | if name, reason not in REPORTED: | | | REPORTED.add name, reason ; log f"skipped: {name} {reason} " | | | | | | def scan ledger : | | | for name in sorted os.listdir INBOX : | | | p = os.path.join INBOX, name | | | if name.startswith "." or not os.path.isfile p : continue | | | if not name.lower .endswith EXT : | | | report once name, "not a .jpg/.jpeg file" ; continue | | | age = time.time - os.path.getmtime p | | | if age < -60: | | | file date in the future: would otherwise never be processed | | | report once name, "file date is in the future, processing anyway" | | | elif age < MIN AGE: | | | continue | | | try: | | | process name, ledger | | | except Exception as e: | | | log f"ERROR with {name}: {e}" | | | try: shutil.move p, unique os.path.join FAILED, name | | | except Exception: log traceback.format exc | | | for name in sorted os.listdir REDO : | | | p = os.path.join REDO, name | | | if name.startswith "." or not os.path.isfile p : continue | | | if not name.lower .endswith EXT : | | | report once name, "not a .jpg/.jpeg file redo folder " ; continue | | | if 0 <= time.time - os.path.getmtime p < 10: continue still being copied | | | try: | | | process name, ledger, REDO, redo=True | | | except Exception as e: | | | log f"ERROR with {name} redo : {e}" | | | try: shutil.move p, unique os.path.join FAILED, name | | | except Exception: log traceback.format exc | | | | | | if name == " main ": | | | for d in INBOX, PROCESSED, FAILED, REDO : make dir d | | | log f"photofresh started keeping originals: {KEEP DAYS:g} days, scanning every {INTERVAL} s " | | | log f"inbox currently contains: {sorted os.listdir INBOX or 'nothing'}" | | | ledger = load ledger | | | while True: | | | try: | | | scan ledger ; cleanup | | | except Exception: | | | log traceback.format exc | | | time.sleep INTERVAL |