#!/usr/bin/env python3 """scan_v2.py — найти некачественные изображения в папке (версия 2). Прогоняет чек-лист детекторов по каждому файлу и останавливается на первой найденной причине. Всё на PIL + numpy, без GPU. Работает и с фото, и с аниме: стилевые вещи (белый фон, тёмная тема, боке, текстура травы/коры, зелёный лес) не считаются браком — флагается только реальный дефект. Подготовка (v2): - ресайз ОДИН раз, LANCZOS (не бикубик/билинейка): меньшая сторона приводится к [512, 1280] — мельче не раздуваем, крупнее не жмём - все метрики считаются на центр-кропе 384×384 (в центре сюжет фото и персонаж аниме) — ресайз+кроп без повторных ресайзов - шум и JPEG-артефакты считаются по оригиналу: ресайз сглаживает шум и ломает сетку 8×8 Дополнительно: classify_media() — фото vs иллюстрация (не брак, а тип контента, для разделения порогов качества). Использование: python3 scan_folder.py ПАПКА # отчёт по папке python3 scan_folder.py --sort ПАПКА [OUT] [--copy] [--limit N] [--no-good] [-j N] python3 scan_folder.py --self-test # проверить детекторы на синтетике --sort раскладывает файлы по папкам-причинам (blur/, noise/, good/...). --copy копировать вместо перемещения --limit N обработать только первые N файлов (в сортированном порядке) --no-good не копировать good — собрать только плохие кейсы -j N параллельных процессов (ускоряет большие прогоны) """ import os import shutil import sys from collections import Counter import multiprocessing import numpy as np from PIL import Image, ImageFilter # --- пороги (калиброваны на смеси фото + аниме) --- # детектор медиа: фото vs иллюстрация (нужен для разделения порогов) MEDIA_CLEAN = 0.8 # std самых плоских 10% блоков ниже — заливка стерильно чистая MEDIA_COLORS = 800 # квантованных цветов ниже — бедная палитра MEDIA_SAT = 20.0 # средняя насыщенность ниже — монохром/пастель MIN_MP = 0.5 # п.0: ниже — низкое разрешение MAX_COLORS = 500 # п.1: ниже — не фото (квантование 5 бит/канал) FLAT_STD = 3.0 # п.1: std центрального блока — «нет фотографического шума» EMPTY_GRAD = 1.0 # п.2: |градиент| сглаженного кадра (переходов нет вообще) EMPTY_FRAC = 0.005 # п.2: доля таких пикселей ниже — пустая поверхность LINE_T = 100.0 # п.3: сила контраста линии LINE_FRAC = 0.015 # п.3: доля связных прямых линий (UI) BLUR_BEST = {"photo": 100.0, "illustration": 180.0} # п.4: лучшая резкость окна ниже — нет резкого места EXP_FRAC = 0.05 # п.5: доля пересвеченных/недодержанных пикселей EXP_MEAN = 230.0 # п.5: пересвет — средняя яркость выше (вся картинка выбита) EXP_MID = 0.22 # п.5: пересвет — доля мидтонов (40..230) ниже (диапазон задавлен) DARK_MEAN = 90.0 # п.5: средняя яркость ниже — темно DARK_BRIGHT = 0.04 # п.5: доля ярких пикселей (>150) ниже — нечего читать NOISE_STD = {"photo": 18.0, "illustration": 15.0} # п.6: std самых гладких блоков (полное разрешение) выше — шум NOISE_RATIO = 1.3 # п.6: S1/S2 после даунсеймпла 2× — шум падает, текстура нет CAST_DELTA = {"photo": 50.0, "illustration": 40.0} # п.7: перекос средних каналов (фото терпимее к тёплому тону) JPEG_RATIO = 1.8 # п.8: разрыв на стыке блока 8×8 / внутри блока (reddit-пережатие даёт ~1.5-1.7) EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"} # Ресайз: один раз, LANCZOS (меньше артефактов, чем бикубик/билинейка). # Меньшая сторона -> [ANALYZE_MIN, ANALYZE_MAX]; метрики — на центр-кропе. ANALYZE_MIN = 512 # мельче не жмём (апскейл до меньшей стороны 512) ANALYZE_MAX = 1280 # крупнее не тянем (даунскейл до меньшей стороны 1280) ANALYZE_CROP = 384 # центр-кроп для метрик: делится на 8/16/48/96 без остатка def prep(img): """Ресайз один раз, LANCZOS: меньшая сторона -> [ANALYZE_MIN, ANALYZE_MAX]. Маленькие картинки не раздуваем больше 512 по меньшей стороне (иначе апскейл плодит артефакты), большие не жмём сильнее 1280 (теряется деталь). Всё остальное — без ресайза: сигнал сохраняется как есть. """ w, h = img.size small = min(w, h) if small < ANALYZE_MIN: s = ANALYZE_MIN / small elif small > ANALYZE_MAX: s = ANALYZE_MAX / small else: s = 1.0 if abs(s - 1.0) > 1e-3: img = img.resize((max(1, round(w * s)), max(1, round(h * s))), Image.LANCZOS) return img def analysis_crop(img): """Центр-кроп ANALYZE_CROP×ANALYZE_CROP из подготовленного изображения. Смотрим на центр: там и сюжет фото, и персонаж аниме. После prep меньшая сторона >= 512 > 384, так что кроп всегда полный. """ w, h = img.size x0 = max(0, (w - ANALYZE_CROP) // 2) y0 = max(0, (h - ANALYZE_CROP) // 2) return img.crop((x0, y0, x0 + ANALYZE_CROP, y0 + ANALYZE_CROP)) def grad(g): gx, gy = np.gradient(g) return gx, gy, np.hypot(gx, gy) def center_std(g): h, w = g.shape y0, y1 = max(0, h // 2 - 32), min(h, h // 2 + 32) x0, x1 = max(0, w // 2 - 32), min(w, w // 2 + 32) b = g[y0:y1, x0:x1] return b.std() if b.size else 0.0 def classify_media(g, rgb): """Фото или иллюстрация? (не брак, а тип контента). Главный признак — «чистота» самых плоских 10% блоков 8×8 (на кропе 384): у иллюстрации заливка стерильная (std~0.1-0.6), у фото даже гладкие зоны имеют шум/зерно (std~2+). Калибровано на 200 фото + 200 аниме: ~2% ложных фото и ~7% ложных аниме. Запасной признак — бедная палитра + низкая насыщенность (монохром/пастель → иллюстрация). Возвращает 'photo' или 'illustration'. """ g = rgb.mean(axis=2) h, w = g.shape hs, ws = h // 8, w // 8 blocks = g[:hs * 8, :ws * 8].reshape(hs, 8, ws, 8).std(axis=(1, 3)) flat = blocks.ravel() k = max(1, flat.size // 10) flat_clean = np.sort(flat)[:k].mean() if flat_clean < MEDIA_CLEAN: return "illustration" u8 = np.clip(rgb, 0, 255).astype(np.uint8) q = (u8[..., 0].astype(np.int32) >> 3) << 10 \ | (u8[..., 1].astype(np.int32) >> 3) << 5 \ | (u8[..., 2].astype(np.int32) >> 3) n_colors = len(np.unique(q)) sat = (rgb.max(axis=2) - rgb.min(axis=2)).mean() if n_colors < MEDIA_COLORS and sat < MEDIA_SAT: return "illustration" return "photo" def check_colors(rgb): """п.1: не фото (мало уникальных цветов и нет шума/текстуры).""" u8 = np.clip(rgb, 0, 255).astype(np.uint8) q = (u8[..., 0].astype(np.int32) >> 3) << 10 \ | (u8[..., 1].astype(np.int32) >> 3) << 5 \ | (u8[..., 2].astype(np.int32) >> 3) n = len(np.unique(q)) if n >= MAX_COLORS: return None if center_std(rgb.mean(axis=2)) > FLAT_STD: # есть текстура/шум — похоже на фото return None return f"не фото (всего {n} цветов)" def check_empty(g): """п.2: пустая поверхность — после сглаживания переходов нет вообще. Шум убираем размытием, чтобы не считать его «структурой». """ sm = np.asarray(Image.fromarray(np.clip(g, 0, 255).astype(np.uint8)) .filter(ImageFilter.GaussianBlur(4)), dtype=np.float32) _, _, sm_gm = grad(sm) if (sm_gm > EMPTY_GRAD).mean() < EMPTY_FRAC: return "пустая поверхность" return None def check_screenshot(g): """п.3: скриншот / фото-в-экране — связные тонкие прямые линии. Связность (>=3 из 5 соседей вдоль линии) отсекает случайный шум текстуры. """ gx, gy, _ = grad(g) t = LINE_T v = (np.abs(gx) > t) & (np.abs(gy) < t / 2) # вертикальные линии h = (np.abs(gy) > t) & (np.abs(gx) < t / 2) # горизонтальные def runs(m, axis): c = np.cumsum(m.astype(np.int16), axis=axis) r = np.zeros_like(m) if axis == 0: r[5:] = (c[5:] - c[:-5]) >= 3 # связность >= 3 из 5 — и вертикали, и горизонтали else: r[:, 5:] = (c[:, 5:] - c[:, :-5]) >= 3 return r vr = runs(v, 0) hr = runs(h, 1) if (vr | hr).mean() > LINE_FRAC: return "скриншот / фото-в-экране" return None def check_blur(g, media=None): """п.4: смаз — в кадре нет ни одной резкой области. Скользящие окна 96×96: если хоть где-то есть резкий участок (объект в фокусе на фоне боке, лайн-арт аниме) — это не смаз. Смаз — когда размыто всё, резких окон нет вообще. Порог зависит от типа контента (фото мягче, аниме — строже). """ blur_best = BLUR_BEST.get(media) if isinstance(BLUR_BEST, dict) else BLUR_BEST gx, gy = np.gradient(g) gm = np.hypot(gx, gy)[1:-1, 1:-1] lap = np.abs(g[2:, 1:-1] + g[:-2, 1:-1] + g[1:-1, 2:] + g[1:-1, :-2] - 4 * g[1:-1, 1:-1]) le = lap.copy() le[gm <= 20] = 0 H, W = le.shape best, ws, stride, min_edges = 0.0, 96, 48, 100 for y in range(0, H - ws, stride): for x in range(0, W - ws, stride): vals = le[y:y + ws, x:x + ws] v = vals[vals > 0] if v.size > min_edges: best = max(best, np.percentile(v, 95)) if best < blur_best: return "смаз / не в фокусе" return None def check_exposure(g, media=None, full_mean=None): """п.5: пересвет / недодержка. Пересвет — выбитый тональный диапазон (по кропу: центр — сюжет). Недодержка — свойство ВСЕГО кадра: тёмный центр при светлом фоне (тёмный персонаж на светлом фоне) — это тёмная тема, не недодержка. media — 'photo'/'illustration' для своих порогов. """ exp_mean = EXP_MEAN.get(media) if isinstance(EXP_MEAN, dict) else EXP_MEAN exp_mid = EXP_MID.get(media) if isinstance(EXP_MID, dict) else EXP_MID dark_mean = DARK_MEAN.get(media) if isinstance(DARK_MEAN, dict) else DARK_MEAN dark_bright = DARK_BRIGHT.get(media) if isinstance(DARK_BRIGHT, dict) else DARK_BRIGHT if (g >= 250).mean() > EXP_FRAC: mid = ((g > 40) & (g < 230)).mean() if g.mean() > exp_mean or mid < exp_mid: return "пересвет" frame_mean = full_mean if full_mean is not None else g.mean() if frame_mean < dark_mean and (g > 150).mean() < dark_bright: return "недодержка" return None def check_noise(g, media=None): """п.6: шум — std самых гладких блоков на полном разрешении. Ресайз сглаживает шум, поэтому меряем по оригиналу. Шум от текстуры отличает масштаб: шум падает примерно вдвое при даунсеймпле 2×, а текстура (трава, кора, листва) — почти нет, ratio S1/S2 ~ 1. Порог зависит от типа (для иллюстраций шум редок — порог ниже). """ noise_std = NOISE_STD.get(media) if isinstance(NOISE_STD, dict) else NOISE_STD h, w = g.shape hs, ws = h // 16, w // 16 if hs == 0 or ws == 0: return None blocks = g[:hs * 16, :ws * 16].reshape(hs, 16, ws, 16).std(axis=(1, 3)) flat = blocks.ravel() k = max(1, flat.size // 10) s1 = np.sort(flat)[:k].mean() if s1 <= noise_std: return None g2 = np.asarray(Image.fromarray(np.clip(g, 0, 255).astype(np.uint8)) .resize((w // 2, h // 2), Image.BILINEAR), dtype=np.float32) h2, w2 = g2.shape hs2, ws2 = h2 // 8, w2 // 8 if hs2 == 0 or ws2 == 0: return None blocks2 = g2[:hs2 * 8, :ws2 * 8].reshape(hs2, 8, ws2, 8).std(axis=(1, 3)) s2 = np.sort(blocks2.ravel())[:max(1, blocks2.size // 10)].mean() if s1 / max(s2, 1e-6) > NOISE_RATIO: return "шум (высокий ISO)" return None def check_cast(rgb, media=None): """п.7: оттенок — перекос каналов на нейтральных пикселях. Глобальное среднее не годится: лес/трава зелёные по своей природе. Ошибка баланса белого сдвигает серые/белые области кадра — судим по ним. Нейтральных пикселей почти нет (чисто цветной кадр) — судить не по чему, не флагаем. media — для своих порогов. """ cast_delta = CAST_DELTA.get(media) if isinstance(CAST_DELTA, dict) else CAST_DELTA mx = rgb.max(axis=2) mn = rgb.min(axis=2) neutral = (mx - mn) < 60 # низкая насыщенность — серые/пастельные области if neutral.mean() < 0.02: return None m = rgb[neutral].mean(axis=0) if m.mean() < 30: # тёмное фото — по средним не судим return None if m.max() - m.min() > cast_delta: return "оттенок / баланс белого" return None def check_jpeg(g): """п.8: артефакты сжатия — разрывы на стыках блоков 8×8. Считается по оригиналу (не по ресайзу) и только по гладким строкам: текстура маскирует блоковость, а ресайз ломает сетку 8×8. """ d = np.abs(np.diff(g, axis=1)) if d.shape[1] < 16: return None smooth = d.mean(axis=1) < 3.0 # строки без сильной текстуры if smooth.sum() < 32: return None d = d[smooth] idx = np.arange(7, d.shape[1], 8) # границы блоков 8×8 inside = np.setdiff1d(np.arange(1, d.shape[1]), idx) di = d[:, inside].mean() ds = d[:, idx].mean() if di < 0.1: # совсем плоская картинка — данных нет return None if ds > JPEG_RATIO * di: return "артефакты сжатия (блоковость 8×8)" return None def scan_img(img): """Один файл -> (диагноз или None, тип контента). Порядок (v2): 1. мегапиксели (самое фундаментальное, из заголовка) 2. classify_media — фото/иллюстрация (не брак, выбор порогов) 3. пустая поверхность (самый «сломанный» кадр) 4. скриншот (специфичный тип — тёмный UI не должен стать недодержкой) 5. пересвет/недодержка (до blur: клип даёт ложный смаз) 6. смаз (резкие окна — контент-агностик) 7. оттенок (после смаза — смаз важнее) 8. шум, 9. JPEG — по оригиналу (ресайз сглаживает шум, ломает сетку 8×8) """ w, h = img.size if w * h / 1e6 < MIN_MP: # 1 — первым, из заголовка return f"низкое разрешение {w}x{h} ({w * h / 1e6:.2f} МП)", None small = prep(img) crop = analysis_crop(small) rgb = np.asarray(crop.convert("RGB"), dtype=np.float32) g = rgb.mean(axis=2) g_prep = np.asarray(small.convert("L"), dtype=np.float32) # весь кадр после ресайза media = classify_media(g, rgb) # 2 — тип контента, пороги под него r = check_empty(g) # 3 if r: return r, media r = check_screenshot(g) # 4 if r: return r, media r = check_exposure(g, media, full_mean=g_prep.mean()) # 5 if r: return r, media r = check_blur(g_prep, media) # 6 — любой резкий окно во всём кадре if r: return r, media r = check_cast(rgb, media) # 7 if r: return r, media g_full = np.asarray(img.convert("L"), dtype=np.float32) r = check_noise(g_full, media) # 8 — по оригиналу if r: return r, media r = check_jpeg(g_full) # 9 — по оригиналу if r: return r, media return None, media def scan_one(path): try: img = Image.open(path) img.load() except Exception as e: return "битый файл: " + str(e)[:60], None return scan_img(img) def category(d): """Диагноз -> имя папки (англ., кратко).""" for c, name in (("низкое разрешение", "low_res"), ("не фото", "not_photo"), ("пустая поверхность", "empty"), ("скриншот", "screenshot"), ("смаз", "blur"), ("пересвет", "overexposed"), ("недодержка", "underexposed"), ("шум", "noise"), ("оттенок", "color_cast"), ("артефакты сжатия", "jpeg_artifacts")): if d and d.startswith(c): return name if d and d.startswith("битый"): return "broken" return "good" FOLDERS = ["low_res", "not_photo", "empty", "screenshot", "blur", "overexposed", "underexposed", "noise", "color_cast", "jpeg_artifacts", "broken", "good"] def unique_dst(dst_dir, name): dst = os.path.join(dst_dir, name) n = 2 while os.path.exists(dst): stem, ext = os.path.splitext(name) dst = os.path.join(dst_dir, f"{stem}_{n}{ext}") n += 1 return dst def sort_folder(folder, out=None, copy=False, limit=None, no_good=False, jobs=1): """Разложить файлы по папкам-причинам. По умолчанию — переместить, --copy — копировать. out по умолчанию: ./<имя сканируемой папки>. limit — обработать только первые N файлов; no_good — не копировать good; jobs — число параллельных процессов. """ if out is None: out = os.path.join(os.getcwd(), os.path.basename(folder.rstrip("/"))) if os.path.abspath(out) == os.path.abspath(folder): out = folder.rstrip("/") + "_sorted" # не раскладывать в самого себя out_abs = os.path.abspath(out) paths = [] for root, _, files in os.walk(folder): root_abs = os.path.abspath(root) if root_abs == out_abs or root_abs.startswith(out_abs + os.sep): continue # уже разложенное не трогаем for f in sorted(files): if os.path.splitext(f)[1].lower() in EXTS: paths.append(os.path.join(root, f)) if limit: paths = sorted(paths)[:limit] counts = Counter() for cat in FOLDERS: os.makedirs(os.path.join(out, cat), exist_ok=True) if jobs > 1 and len(paths) > 1: with multiprocessing.Pool(jobs) as pool: diags = pool.map(scan_one, paths, chunksize=32) else: diags = [scan_one(p) for p in paths] media_cnt = Counter(m for _, m in diags if m) for p, (d, media) in zip(paths, diags): cat = category(d) if no_good and cat == "good": counts["good (не скопировано)"] += 1 continue dst = unique_dst(os.path.join(out, cat), os.path.basename(p)) (shutil.copy2 if copy else shutil.move)(p, dst) counts[cat] += 1 print(f"{'Скопировано' if copy else 'Перемещено'} {len(paths)} файлов в {out}:") for k, v in counts.most_common(): print(f" {k}: {v}") if media_cnt: print(" по типу:", dict(media_cnt)) def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(1) if sys.argv[1] == "--self-test": sys.exit(0 if self_test() else 1) if sys.argv[1] == "--sort": argv = sys.argv[2:] copy = "--copy" in argv no_good = "--no-good" in argv limit = None jobs = 1 args = [] i = 0 while i < len(argv): a = argv[i] if a == "--limit": limit = int(argv[i + 1]); i += 1 elif a in ("-j", "--jobs"): jobs = int(argv[i + 1]); i += 1 elif a not in ("--copy", "--no-good"): args.append(a) i += 1 if not args: print("Использование: scan_folder.py --sort ПАПКА [OUT] [--copy] [--limit N] [--no-good] [-j N]") sys.exit(1) sort_folder(args[0], args[1] if len(args) > 1 else None, copy=copy, limit=limit, no_good=no_good, jobs=jobs) return folder = sys.argv[1] results = [] for root, _, files in os.walk(folder): for f in sorted(files): if os.path.splitext(f)[1].lower() in EXTS: p = os.path.join(root, f) results.append((p, scan_one(p))) stats = Counter(d or "чисто" for _, (d, _) in results) for p, (d, media) in results: if d: print(f"{p}: {d} [{media or '?'}]") print(f"\nИтого: {sum(1 for _, (d, _) in results if d)} из {len(results)} — брак") for k, v in stats.most_common(): print(f" {k}: {v}") def self_test(): """Синтетика: каждый детектор срабатывает на своём кейсе и не срабатывает на стилевых (белый фон, тёмная тема, боке).""" rng = np.random.default_rng(0) H, W = 960, 960 # 0.92 МП — не триггерит п.0; до 1024 апскейлит prep ok = True def noise(shape, sigma): return rng.normal(0, sigma, shape) # без clip — это шум, а не картинка def make_photo(rect=(250, 650, 300, 900), texture_s=40, noise_s=4, bg=(70, 170, 80, 180, 90, 190)): """Фон-градиенты по независимым направлениям каналов + объект + шум.""" r0, r1, g0, g1, b0, b1 = bg xx = np.linspace(0, 1, W, dtype=np.float32)[None, :] yy = np.linspace(0, 1, H, dtype=np.float32)[:, None] img = np.stack([r0 + (r1 - r0) * np.broadcast_to(xx, (H, W)), g0 + (g1 - g0) * np.broadcast_to(yy, (H, W)), b0 + (b1 - b0) * (xx + yy) / 2], axis=2) y0, y1, x0, x1 = rect tex = np.clip(128 + rng.normal(0, texture_s, (y1 - y0, x1 - x0, 3)), 0, 255).astype(np.uint8) img[y0:y1, x0:x1] = tex return np.clip(img.astype(np.int16) + noise((H, W, 3), noise_s), 0, 255).astype(np.uint8) def expect(img, want): nonlocal ok got = scan_img(img)[0] good = (got is None and want is None) or (want is not None and got == want) if not good: print(f" FAIL: ожидал {want!r}, получил {got!r}") ok = False norm = make_photo() expect(Image.fromarray(norm), None) expect(Image.fromarray(norm).resize((64, 48)), "низкое разрешение 64x48 (0.00 МП)") # стена: однотонная → пустая поверхность (не фото в v2 — это тип, не брак) expect(Image.fromarray(np.full((H, W, 3), 200, np.uint8)), "пустая поверхность") # небо: плавный градиент + лёгкий шум xx = np.linspace(0, 255, W, dtype=np.float32)[None, :] sky = np.dstack([np.clip(xx + noise((H, W), 1), 0, 255).astype(np.uint8)] * 3) expect(Image.fromarray(sky), "пустая поверхность") # скриншот: прямые линии + «фото» внутри scr = np.full((H, W, 3), 240, np.uint8) for y in (50, 150, 250, 350, 450, 550, 650, 750, 850, 910): scr[y:y + 3, :] = 0 for x in (51, 151, 251, 351, 451, 551, 651, 751): # нечётные — мимо границ 8×8 scr[:, x:x + 3] = 0 scr[150:450, 200:700] = norm[150:450, 200:700] expect(Image.fromarray(scr), "скриншот / фото-в-экране") # смаз: фото с мягкой текстурой, сильно размытое (широкий диапазон — цвета живы) clean = make_photo(rect=(650, 900, 600, 900), texture_s=25, noise_s=8, bg=(0, 255, 0, 255, 0, 255)) blurred = np.asarray(Image.fromarray(clean).filter(ImageFilter.GaussianBlur(8))) expect(Image.fromarray(blurred), "смаз / не в фокусе") # пересвет: выбит весь кадр, но цвета ещё живы (яркость поднята до клипа) bright = np.clip(norm.astype(np.float32) * 1.9 + 30, 0, 255).astype(np.uint8) expect(Image.fromarray(bright), "пересвет") # «белый фон»: выбита только половина кадра, вторая нормальная — НЕ пересвет mask = np.zeros((H, W), np.float32) mask[:int(0.5 * H)] = 200.0 mask = np.asarray(Image.fromarray(mask.astype(np.uint8)).filter(ImageFilter.GaussianBlur(10)), np.float32)[..., None] partial = np.clip(norm.astype(np.float32) + mask, 0, 255).astype(np.uint8) expect(Image.fromarray(partial), None) # недодержка: весь кадр тёмный, читать нечего (текстура с шумом остаётся) base_dark = make_photo(texture_s=80, noise_s=8) dark_full = np.clip(base_dark.astype(np.float32) * 0.6 + noise((H, W, 3), 15), 0, 255).astype(np.uint8) expect(Image.fromarray(dark_full), "недодержка") # тёмный кадр со светлым объектом — читать есть что, НЕ недодержка dark_neg = np.clip(base_dark.astype(np.float32) * 0.5 + 5, 0, 255).astype(np.uint8) dark_neg[150:450, 150:450] = 220 expect(Image.fromarray(dark_neg), None) # шум: сильный (без клипа в 255 — насыщение прячет шум) noisy = np.clip(norm.astype(np.int16) + noise((H, W, 3), 30), 0, 255).astype(np.uint8) expect(Image.fromarray(noisy), "шум (высокий ISO)") # оттенок: синий перекос на сером фоне + цветной блок справа (реальный WB-сдвиг) scene = np.full((H, W, 3), 200, np.uint8) scene[100:900, 500:900] = norm[100:900, 500:900] cast = np.clip(scene.astype(np.int16) + np.array([0, 0, 50], np.int16), 0, 255).astype(np.uint8) expect(Image.fromarray(cast), "оттенок / баланс белого") expect(Image.fromarray(scene), None) # серый фон без сдвига — не оттенок # артефакты сжатия: нормальное фото, пережатое в JPEG import tempfile tmp = os.path.join(tempfile.gettempdir(), "scan_test_q50.jpg") Image.fromarray(norm).save(tmp, "JPEG", quality=50) expect(Image.open(tmp), "артефакты сжатия (блоковость 8×8)") if ok: print("self-test: все детекторы работают") return ok if __name__ == "__main__": main()