recoilme's picture
Upload quality/scan.py with huggingface_hub
c05fa0d verified
Raw
History Blame Contribute Delete
30.5 kB
#!/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()