| |
| """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 |
|
|
| |
| |
| MEDIA_CLEAN = 0.8 |
| MEDIA_COLORS = 800 |
| MEDIA_SAT = 20.0 |
| MIN_MP = 0.5 |
| MAX_COLORS = 500 |
| FLAT_STD = 3.0 |
| EMPTY_GRAD = 1.0 |
| EMPTY_FRAC = 0.005 |
| LINE_T = 100.0 |
| LINE_FRAC = 0.015 |
| BLUR_BEST = {"photo": 100.0, "illustration": 180.0} |
| EXP_FRAC = 0.05 |
| EXP_MEAN = 230.0 |
| EXP_MID = 0.22 |
| DARK_MEAN = 90.0 |
| DARK_BRIGHT = 0.04 |
| NOISE_STD = {"photo": 18.0, "illustration": 15.0} |
| NOISE_RATIO = 1.3 |
| CAST_DELTA = {"photo": 50.0, "illustration": 40.0} |
| JPEG_RATIO = 1.8 |
|
|
| EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"} |
| |
| |
| ANALYZE_MIN = 512 |
| ANALYZE_MAX = 1280 |
| ANALYZE_CROP = 384 |
|
|
|
|
| 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 |
| 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) |
| 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: |
| 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) |
| r = check_empty(g) |
| if r: |
| return r, media |
| r = check_screenshot(g) |
| if r: |
| return r, media |
| r = check_exposure(g, media, full_mean=g_prep.mean()) |
| if r: |
| return r, media |
| r = check_blur(g_prep, media) |
| if r: |
| return r, media |
| r = check_cast(rgb, media) |
| if r: |
| return r, media |
| g_full = np.asarray(img.convert("L"), dtype=np.float32) |
| r = check_noise(g_full, media) |
| if r: |
| return r, media |
| r = check_jpeg(g_full) |
| 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 |
| ok = True |
|
|
| def noise(shape, sigma): |
| return rng.normal(0, sigma, shape) |
|
|
| 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 МП)") |
|
|
| |
| 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): |
| 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) |
|
|
| |
| noisy = np.clip(norm.astype(np.int16) + noise((H, W, 3), 30), 0, 255).astype(np.uint8) |
| expect(Image.fromarray(noisy), "шум (высокий ISO)") |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|