{"slug": "photofresh-adaptive-photo-touch-up-for-phone-photos", "title": "photofresh - adaptive photo touch-up for phone photos", "summary": "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.", "body_md": "|  | # photofresh.py - adaptive, per-photo touch-up for phone photos (no AI, nothing invented) | \n|  | # | \n|  | # Watches an inbox folder, gently improves each new JPEG and writes the result to an | \n|  | # output folder (e.g. an Immich external library), sorted into year/month subfolders. | \n|  | # | \n|  | # Folders (inside the container): | \n|  | #   /data/inbox      <- new photos arrive here (e.g. via Syncthing) | \n|  | #   /data/processed  <- originals after processing (auto-deleted after KEEP_DAYS) | \n|  | #   /data/failed     <- photos that could not be processed (never deleted) | \n|  | #   /data/redo       <- put an original here to process it again and overwrite the old result | \n|  | #   /output          <- processed photos, in year/month subfolders | \n|  | # | \n|  | # Per photo it MEASURES first and only then adjusts: | \n|  | #   1. white balance: corrects half of the measured colour cast (max 12%) | \n|  | #   2. contrast range: stretched only if the histogram is compressed (haze, flat light) | \n|  | #   3. midtone lift: only for photos that are too dark | \n|  | #   4. local contrast (CLAHE), mixed in at 40% | \n|  | #   5. noise reduction: only if measured noise is high, and only on flat areas (walls, sky) | \n|  | #   \"Night mode\": high ISO + dark photo -> keep the darkness, only clean up noise. | \n|  | #   All EXIF data (date, GPS, orientation) is copied unchanged. | \n|  | import os, time, shutil, subprocess, traceback | \n|  | import cv2, numpy as np | \n|  |  | \n|  | DATA = \"/data\" | \n|  | INBOX, PROCESSED, FAILED, REDO = (f\"{DATA}/{d}\" for d in (\"inbox\", \"processed\", \"failed\", \"redo\")) | \n|  | OUTPUT = \"/output\" | \n|  | LEDGER, LOG = f\"{DATA}/processed.list\", f\"{DATA}/photofresh.log\" | \n|  | KEEP_DAYS = float(os.environ.get(\"KEEP_DAYS\", \"30\"))     # 0 = delete originals right after processing | \n|  | SHARPEN = float(os.environ.get(\"SHARPEN\", \"0\"))          # 0 = off (default), 1 = light sharpening | \n|  | NIGHT_ISO = float(os.environ.get(\"NIGHT_ISO\", \"1000\"))   # ISO from which a dark photo counts as a night shot (0 = off) | \n|  | PUID, PGID = int(os.environ.get(\"PUID\", \"99\")), int(os.environ.get(\"PGID\", \"100\"))  # 99/100 = Unraid default | \n|  | JPEG_QUALITY = int(os.environ.get(\"JPEG_QUALITY\", \"95\")) | \n|  | INTERVAL = 120        # seconds between scans | \n|  | MIN_AGE = 30          # a file must be unchanged for this long (still syncing otherwise) | \n|  | EXT = (\".jpg\", \".jpeg\") | \n|  |  | \n|  | def log(msg): | \n|  | line = time.strftime(\"%Y-%m-%d %H:%M:%S \") + msg | \n|  | print(line, flush=True) | \n|  | with open(LOG, \"a\") as f: f.write(line + \"\\n\") | \n|  |  | \n|  | def set_owner(path, is_dir=False): | \n|  | try: | \n|  | os.chown(path, PUID, PGID) | \n|  | os.chmod(path, 0o777 if is_dir else 0o666) | \n|  | except Exception: pass | \n|  |  | \n|  | def make_dir(path): | \n|  | if not os.path.isdir(path): | \n|  | os.makedirs(path, exist_ok=True) | \n|  | set_owner(path, True) | \n|  |  | \n|  | # ---------- the algorithm ---------- | \n|  | def noise_sigma(gray): | \n|  | \"\"\"Estimate noise only in the 15% flattest blocks, so texture (grass, hair) doesn't count as noise.\"\"\" | \n|  | g = gray.astype(np.float32) | \n|  | hp = g - cv2.GaussianBlur(g, (0, 0), 1.5) | \n|  | grad = cv2.GaussianBlur(np.abs(cv2.Sobel(cv2.GaussianBlur(g, (0, 0), 3), cv2.CV_32F, 1, 1)), (0, 0), 3) | \n|  | B = 32; vals = []; flat = [] | \n|  | for y in range(0, g.shape[0] - B, B): | \n|  | for x in range(0, g.shape[1] - B, B): | \n|  | m = g[y:y+B, x:x+B].mean() | \n|  | if 25 < m < 235: | \n|  | flat.append(grad[y:y+B, x:x+B].mean()) | \n|  | vals.append(np.median(np.abs(hp[y:y+B, x:x+B])) * 1.4826) | \n|  | if not vals: return 0.0 | \n|  | flat = np.array(flat); vals = np.array(vals) | \n|  | return float(np.median(vals[flat <= np.percentile(flat, 15)])) | \n|  |  | \n|  | def enhance(bgr, iso=None): | \n|  | info = {} | \n|  | img = bgr.astype(np.float32) / 255.0 | \n|  | # night shot? (high ISO and dark image): keep the darkness, it is the mood | \n|  | mean = float(np.mean(cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY))) / 255 | \n|  | night = bool(NIGHT_ISO > 0 and iso is not None and iso >= NIGHT_ISO and mean < 0.40) | \n|  | info[\"night\"] = night | \n|  | # 1. white balance: measure the cast on near-grey pixels, correct 50% (25% at night), max 12% | \n|  | hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) | \n|  | v, s = hsv[..., 2], hsv[..., 1] | \n|  | mask = (s < 60) & (v > 40) & (v < 240) | \n|  | if mask.sum() < 0.02 * mask.size: mask = (v > 20) & (v < 245) | \n|  | means = np.array([img[..., c][mask].mean() for c in range(3)]) | \n|  | strength = 0.25 if night else 0.5 | \n|  | gains = np.clip(1 + strength * (means.mean() / means - 1), 0.88, 1.12) | \n|  | img = np.clip(img * gains, 0, 1) | \n|  | info[\"wb\"] = [round(float(x), 3) for x in gains] | \n|  | # 2. stretch the contrast range (may clip a tiny bit) | \n|  | lab = cv2.cvtColor((img * 255).astype(np.uint8), cv2.COLOR_BGR2LAB) | \n|  | L = lab[..., 0].astype(np.float32) | \n|  | lo, hi = np.percentile(L, 0.5), np.percentile(L, 99.5) | \n|  | lo_new = lo - 0.7 * max(lo - 6, 0) | \n|  | hi_new = hi + 0.8 * max(252 - hi, 0) | \n|  | if hi - lo > 10 and not night: | \n|  | L = (L - lo) / (hi - lo) * (hi_new - lo_new) + lo_new | \n|  | # 3. photo too dark: gentle midtone lift | \n|  | meanL = float(np.mean(np.clip(L, 0, 255))) / 255 | \n|  | if meanL < 0.42 and not night: | \n|  | gamma = max(0.85, np.log(0.45) / np.log(max(meanL, 1e-3))) | \n|  | L = 255 * np.power(np.clip(L / 255, 0, 1), gamma) | \n|  | # 4. adaptive local contrast (CLAHE), mixed in at 40%; skipped at night (it lifts shadows and grain) | \n|  | L8 = np.clip(L, 0, 255).astype(np.uint8) | \n|  | if not night: | \n|  | L8 = cv2.addWeighted(L8, 0.6, cv2.createCLAHE(clipLimit=1.6, tileGridSize=(8, 8)).apply(L8), 0.4, 0) | \n|  | lab[..., 0] = L8 | \n|  | out = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) | \n|  | # 5. noise reduction only when needed, and only on flat areas | \n|  | sig = noise_sigma(cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)) | \n|  | info[\"noise\"] = round(sig, 2) | \n|  | s_ref = max(sig, 0.8) | \n|  | g = cv2.cvtColor(out, cv2.COLOR_BGR2GRAY).astype(np.float32) | \n|  | tex = cv2.GaussianBlur(np.abs(g - cv2.GaussianBlur(g, (0, 0), 2)), (0, 0), 4) | \n|  | texture = np.clip((tex - s_ref) / (3.0 * s_ref), 0, 1)   # 0 = flat, 1 = texture/edge | \n|  | if sig > 1.2: | \n|  | # luminance noise (grain): gentle; colour noise (speckles): firm | \n|  | h_lum = float(np.clip((sig - 1.2) * 0.8 + 2.5, 2.5, 5.0)) | \n|  | h_col = float(np.clip((sig - 1.2) * 2.0 + 6.0, 6.0, 14.0)) | \n|  | den = cv2.fastNlMeansDenoisingColored(out, None, h_lum, h_col, 5, 15) | \n|  | w = (1 - texture)[..., None] | \n|  | out = (den * w + out * (1 - w)).astype(np.uint8) | \n|  | # 6. optional light sharpening (OFF by default): luminance only, textured areas only, | \n|  | #    ignores differences below the noise level, clamped to avoid halos | \n|  | if SHARPEN > 0: | \n|  | lab = cv2.cvtColor(out, cv2.COLOR_BGR2LAB) | \n|  | Lf = lab[..., 0].astype(np.float32) | \n|  | detail = Lf - cv2.GaussianBlur(Lf, (0, 0), 1.0) | \n|  | thr = max(1.5, 1.2 * sig) | \n|  | detail = np.sign(detail) * np.maximum(np.abs(detail) - thr, 0) | \n|  | detail = np.clip(detail, -12, 12) | \n|  | amount = (0.7 if sig < 3 else 0.5) * SHARPEN | \n|  | lab[..., 0] = np.clip(Lf + amount * detail * texture, 0, 255).astype(np.uint8) | \n|  | out = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) | \n|  | return out, info | \n|  |  | \n|  | # ---------- file handling ---------- | \n|  | def load_ledger(): | \n|  | if not os.path.exists(LEDGER): return set() | \n|  | with open(LEDGER) as f: return set(l.strip() for l in f if l.strip()) | \n|  |  | \n|  | def read_iso(src): | \n|  | try: | \n|  | r = subprocess.run([\"exiftool\", \"-s3\", \"-n\", \"-ISO\", src], capture_output=True, text=True, timeout=30) | \n|  | return float(r.stdout.strip().split()[0]) | \n|  | except Exception: | \n|  | return None | \n|  |  | \n|  | def target_subdir(src): | \n|  | try: | \n|  | r = subprocess.run([\"exiftool\", \"-s3\", \"-d\", \"%Y/%m\", \"-DateTimeOriginal\", src], | \n|  | capture_output=True, text=True, timeout=30) | \n|  | sub = r.stdout.strip() | \n|  | if len(sub) == 7 and sub[4] == \"/\": return sub | \n|  | except Exception: pass | \n|  | return time.strftime(\"%Y/%m\", time.localtime(os.path.getmtime(src))) | \n|  |  | \n|  | def unique(path): | \n|  | if not os.path.exists(path): return path | \n|  | base, ext = os.path.splitext(path); i = 1 | \n|  | while os.path.exists(f\"{base}_{i}{ext}\"): i += 1 | \n|  | return f\"{base}_{i}{ext}\" | \n|  |  | \n|  | def process(name, ledger, folder=INBOX, redo=False): | \n|  | src = os.path.join(folder, name) | \n|  | key = f\"{name}\\|{os.path.getsize(src)}\" | \n|  | if key in ledger and not redo: | \n|  | # already done (e.g. re-synced): don't duplicate it in the library, but don't delete it either | \n|  | shutil.move(src, unique(os.path.join(PROCESSED, name))) | \n|  | log(f\"skipped (already processed), moved to 'processed': {name}  -> put it in 'redo' to process it again\"); return | \n|  | # IGNORE_ORIENTATION: don't rotate pixels, so the original Orientation tag stays correct | \n|  | bgr = cv2.imread(src, cv2.IMREAD_COLOR \\| cv2.IMREAD_IGNORE_ORIENTATION) | \n|  | if bgr is None: raise RuntimeError(\"could not read photo\") | \n|  | iso = read_iso(src) | \n|  | out, info = enhance(bgr, iso) | \n|  | sub = target_subdir(src) | \n|  | make_dir(os.path.join(OUTPUT, sub[:4])); make_dir(os.path.join(OUTPUT, sub)) | \n|  | dst = os.path.join(OUTPUT, sub, name) | \n|  | if not redo: dst = unique(dst)          # in 'redo' the old result is overwritten | \n|  | tmp = dst + \".tmp.jpg\" | \n|  | if not cv2.imwrite(tmp, out, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]): raise RuntimeError(\"write failed\") | \n|  | r = subprocess.run([\"exiftool\", \"-q\", \"-overwrite_original\", \"-TagsFromFile\", src, \"-all:all\", tmp], | \n|  | capture_output=True, text=True, timeout=60) | \n|  | if r.returncode != 0: raise RuntimeError(\"copying metadata failed: \" + r.stderr.strip()) | \n|  | if cv2.imread(tmp) is None: raise RuntimeError(\"result check failed\") | \n|  | if not redo:   # in 'redo' the file gets the current time so the library notices the change | \n|  | st = os.stat(src); os.utime(tmp, (st.st_atime, st.st_mtime)) | \n|  | os.rename(tmp, dst); set_owner(dst) | \n|  | if key not in ledger: | \n|  | with open(LEDGER, \"a\") as f: f.write(key + \"\\n\") | \n|  | ledger.add(key) | \n|  | if KEEP_DAYS > 0: | \n|  | keep = unique(os.path.join(PROCESSED, name)) | \n|  | shutil.move(src, keep) | \n|  | os.utime(keep, None)   # retention clock starts now, not at the capture date | \n|  | else: | \n|  | os.remove(src) | \n|  | log(f\"{'redo ' if redo else ''}done: {name} -> {sub}/  iso={iso} night={'yes' if info['night'] else 'no'} wb={info['wb']} noise={info['noise']}\") | \n|  |  | \n|  | def cleanup(): | \n|  | limit = time.time() - KEEP_DAYS * 86400 | \n|  | for n in os.listdir(PROCESSED): | \n|  | p = os.path.join(PROCESSED, n) | \n|  | if os.path.isfile(p) and os.path.getmtime(p) < limit: | \n|  | os.remove(p); log(f\"original deleted after {KEEP_DAYS:g} days: {n}\") | \n|  |  | \n|  | REPORTED = set()   # so each skipped file is logged only once | \n|  |  | \n|  | def report_once(name, reason): | \n|  | if (name, reason) not in REPORTED: | \n|  | REPORTED.add((name, reason)); log(f\"skipped: {name} ({reason})\") | \n|  |  | \n|  | def scan(ledger): | \n|  | for name in sorted(os.listdir(INBOX)): | \n|  | p = os.path.join(INBOX, name) | \n|  | if name.startswith(\".\") or not os.path.isfile(p): continue | \n|  | if not name.lower().endswith(EXT): | \n|  | report_once(name, \"not a .jpg/.jpeg file\"); continue | \n|  | age = time.time() - os.path.getmtime(p) | \n|  | if age < -60: | \n|  | # file date in the future: would otherwise never be processed | \n|  | report_once(name, \"file date is in the future, processing anyway\") | \n|  | elif age < MIN_AGE: | \n|  | continue | \n|  | try: | \n|  | process(name, ledger) | \n|  | except Exception as e: | \n|  | log(f\"ERROR with {name}: {e}\") | \n|  | try: shutil.move(p, unique(os.path.join(FAILED, name))) | \n|  | except Exception: log(traceback.format_exc()) | \n|  | for name in sorted(os.listdir(REDO)): | \n|  | p = os.path.join(REDO, name) | \n|  | if name.startswith(\".\") or not os.path.isfile(p): continue | \n|  | if not name.lower().endswith(EXT): | \n|  | report_once(name, \"not a .jpg/.jpeg file (redo folder)\"); continue | \n|  | if 0 <= time.time() - os.path.getmtime(p) < 10: continue   # still being copied | \n|  | try: | \n|  | process(name, ledger, REDO, redo=True) | \n|  | except Exception as e: | \n|  | log(f\"ERROR with {name} (redo): {e}\") | \n|  | try: shutil.move(p, unique(os.path.join(FAILED, name))) | \n|  | except Exception: log(traceback.format_exc()) | \n|  |  | \n|  | if __name__ == \"__main__\": | \n|  | for d in (INBOX, PROCESSED, FAILED, REDO): make_dir(d) | \n|  | log(f\"photofresh started (keeping originals: {KEEP_DAYS:g} days, scanning every {INTERVAL} s)\") | \n|  | log(f\"inbox currently contains: {sorted(os.listdir(INBOX)) or 'nothing'}\") | \n|  | ledger = load_ledger() | \n|  | while True: | \n|  | try: | \n|  | scan(ledger); cleanup() | \n|  | except Exception: | \n|  | log(traceback.format_exc()) | \n|  | time.sleep(INTERVAL) |", "url": "https://wpnews.pro/news/photofresh-adaptive-photo-touch-up-for-phone-photos", "canonical_source": "https://gist.github.com/ludostaatopstal/7ea70d1408e20648ab5a7662fcbcf823", "published_at": "2026-10-06 19:30:25+00:00", "updated_at": "2026-10-06 21:18:42.842416+00:00", "lang": "en", "topics": ["computer-vision", "ai-tools", "developer-tools"], "entities": ["photofresh", "Immich", "Syncthing", "OpenCV", "NumPy", "Unraid"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/photofresh-adaptive-photo-touch-up-for-phone-photos", "markdown": "https://wpnews.pro/news/photofresh-adaptive-photo-touch-up-for-phone-photos.md", "text": "https://wpnews.pro/news/photofresh-adaptive-photo-touch-up-for-phone-photos.txt", "jsonld": "https://wpnews.pro/news/photofresh-adaptive-photo-touch-up-for-phone-photos.jsonld"}}