File size: 47,152 Bytes
c9a5b52
d3aa7a5
c9a5b52
 
 
 
 
 
d3aa7a5
 
 
 
 
 
 
 
 
 
c9a5b52
 
 
 
a658098
 
 
c9a5b52
 
a658098
 
 
 
 
 
 
 
c9a5b52
 
 
 
 
a658098
 
 
 
 
 
 
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
d3aa7a5
 
 
 
c9a5b52
 
 
 
 
 
 
a658098
c9a5b52
 
 
 
 
d3aa7a5
c9a5b52
c05fa0d
 
c9a5b52
 
d3aa7a5
 
 
 
 
c9a5b52
 
 
d3aa7a5
c9a5b52
d3aa7a5
 
 
c9a5b52
 
d3aa7a5
 
 
 
 
 
 
c9a5b52
d3aa7a5
c9a5b52
 
 
d3aa7a5
 
 
 
 
 
 
 
 
 
 
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
d3aa7a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9a5b52
 
 
 
 
 
 
 
a658098
c9a5b52
a658098
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
a658098
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
 
d3aa7a5
c9a5b52
d3aa7a5
 
 
 
c9a5b52
d3aa7a5
 
c9a5b52
a658098
 
c9a5b52
 
a658098
 
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
a658098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9a5b52
 
d3aa7a5
c9a5b52
 
d3aa7a5
 
 
 
c9a5b52
d3aa7a5
 
 
 
c9a5b52
 
d3aa7a5
a658098
d3aa7a5
 
a658098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9a5b52
 
d3aa7a5
c9a5b52
 
 
 
 
d3aa7a5
c9a5b52
d3aa7a5
c9a5b52
a658098
d3aa7a5
a658098
c9a5b52
 
 
 
 
a658098
c9a5b52
 
 
a658098
 
c9a5b52
 
a658098
 
 
c9a5b52
 
 
 
a658098
c9a5b52
 
a658098
 
c9a5b52
 
a658098
 
c9a5b52
a658098
 
 
 
c9a5b52
a658098
 
 
 
 
 
 
 
 
 
c9a5b52
 
a658098
c9a5b52
 
a658098
c9a5b52
 
 
 
 
 
a658098
 
c9a5b52
 
a658098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3aa7a5
 
 
 
 
 
 
 
 
 
 
c9a5b52
d3aa7a5
a658098
c9a5b52
d3aa7a5
 
c9a5b52
d3aa7a5
 
a658098
d3aa7a5
a658098
 
d3aa7a5
a658098
 
d3aa7a5
a658098
 
d3aa7a5
a658098
 
d3aa7a5
a658098
c9a5b52
a658098
c9a5b52
a658098
 
c9a5b52
a658098
 
 
 
 
 
 
 
c9a5b52
 
 
 
 
 
 
d3aa7a5
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a658098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3aa7a5
 
c9a5b52
 
 
 
 
 
 
 
 
 
d3aa7a5
 
c9a5b52
 
 
 
 
 
 
 
a658098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3aa7a5
 
c9a5b52
d3aa7a5
 
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3aa7a5
c9a5b52
 
 
 
 
 
 
 
 
d3aa7a5
 
c9a5b52
 
 
 
 
 
 
 
d3aa7a5
c9a5b52
d3aa7a5
c9a5b52
 
 
 
 
d3aa7a5
c9a5b52
 
 
 
d3aa7a5
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3aa7a5
c9a5b52
d3aa7a5
c9a5b52
 
d3aa7a5
c9a5b52
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
#!/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 --top ПАПКА [OUT] [--count N] [--copy] [--categories a,b,c] [-j N]
    python3 scan_folder.py --scores ПАПКА OUT.tsv [-j N]
    python3 scan_folder.py --select TSV OUT [--counts good=1000,blur=1000] [--include-lowres]
    python3 scan_folder.py --self-test              # проверить детекторы на синтетике

--scores пишет tsv со скорами [0..1] по каждой категории для каждого изображения
(путь | категория | диагноз | media | blur | underexposed | overexposed | noise |
jpeg_artifacts | color_cast | screenshot | not_photo | empty | low_res | good).

--select по tsv собирает топ-N самых «характерных» примеров по каждой категории
(где категория — первичный диагноз, ранжирование по скору) и копирует с парным
.txt в OUT/<категория>/. По умолчанию low_res исключается; --include-lowres вернёт.

--sort раскладывает файлы по папкам-причинам (blur/, noise/, good/...).
  --copy      копировать вместо перемещения
  --limit N   обработать только первые N файлов (в сортированном порядке)
  --no-good   не копировать good — собрать только плохие кейсы
  -j N        параллельных процессов (ускоряет большие прогоны)

--top берёт топ-N самых «плохих» по каждой категории (по метрике выраженности:
самые размытые, самые тёмные, самый сильный шум и т.д.). Копирует/переносит
изображение + парный .txt в OUT/<категория>/.
  --count N   сколько примеров на категорию (по умолчанию 10)
  --categories blur,underexposed,...  какие категории (по умолчанию — все с метрикой)
  --copy      копировать вместо перемещения
  -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": 90.0, "illustration": 150.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, None
    if center_std(rgb.mean(axis=2)) > FLAT_STD:  # есть текстура/шум — похоже на фото
        return None, None
    return f"не фото (всего {n} цветов)", None


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 "пустая поверхность", None
    return None, 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 "скриншот / фото-в-экране", None
    return None, None


def blur_best(g):
    """Метрика резкости: 95-й перцентиль лапласиана в лучшем окне 96×96.
    Меньше = размытее. Используется и в детекторе, и для скоринга."""
    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))
    return float(best)


def check_blur(g, media=None):
    """п.4: смаз — в кадре нет ни одной резкой области.

    Скользящие окна 96×96: если хоть где-то есть резкий участок
    (объект в фокусе на фоне боке, лайн-арт аниме) — это не смаз.
    Смаз — когда размыто всё, резких окон нет вообще.
    Порог зависит от типа контента (фото мягче, аниме — строже).
    """
    th = BLUR_BEST.get(media) if isinstance(BLUR_BEST, dict) else BLUR_BEST
    best = blur_best(g)
    if best < th:
        return "смаз / не в фокусе", best
    return None, 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 "пересвет", float((g >= 250).mean())
    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 "недодержка", float(frame_mean)
    return None, None


def noise_s1(g):
    """Метрика шума: std самых гладких 10% блоков 16×16 (полное разрешение).
    Больше = шумнее. Используется и в детекторе, и для скоринга."""
    h, w = g.shape
    hs, ws = h // 16, w // 16
    if hs == 0 or ws == 0:
        return 0.0
    blocks = g[:hs * 16, :ws * 16].reshape(hs, 16, ws, 16).std(axis=(1, 3))
    flat = blocks.ravel()
    k = max(1, flat.size // 10)
    return float(np.sort(flat)[:k].mean())


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
    s1 = noise_s1(g)
    if s1 <= noise_std:
        return None, 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, 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)", s1
    return None, None


def cast_delta(rgb):
    """Метрика оттенка: перекос каналов на нейтральных пикселях.
    Больше = сильнее оттенок. Используется и в детекторе, и для скоринга."""
    mx = rgb.max(axis=2)
    mn = rgb.min(axis=2)
    neutral = (mx - mn) < 60  # низкая насыщенность — серые/пастельные области
    if neutral.mean() < 0.02:
        return 0.0
    m = rgb[neutral].mean(axis=0)
    if m.mean() < 30:  # тёмное фото — по средним не судим
        return 0.0
    return float(m.max() - m.min())


def check_cast(rgb, media=None):
    """п.7: оттенок — перекос каналов на нейтральных пикселях.

    Глобальное среднее не годится: лес/трава зелёные по своей природе.
    Ошибка баланса белого сдвигает серые/белые области кадра — судим
    по ним. Нейтральных пикселей почти нет (чисто цветной кадр) —
    судить не по чему, не флагаем. media — для своих порогов.
    """
    th = CAST_DELTA.get(media) if isinstance(CAST_DELTA, dict) else CAST_DELTA
    delta = cast_delta(rgb)
    if delta > th:
        return "оттенок / баланс белого", delta
    return None, None


def jpeg_ratio(g):
    """Метрика блоковости: разрыв на стыках 8×8 / внутри блоков.
    Больше = сильнее артефакты. Используется и в детекторе, и для скоринга."""
    d = np.abs(np.diff(g, axis=1))
    if d.shape[1] < 16:
        return 0.0
    smooth = d.mean(axis=1) < 3.0  # строки без сильной текстуры
    if smooth.sum() < 32:
        return 0.0
    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 0.0
    return float(ds / di)


def check_jpeg(g):
    """п.8: артефакты сжатия — разрывы на стыках блоков 8×8.

    Считается по оригиналу (не по ресайзу) и только по гладким строкам:
    текстура маскирует блоковость, а ресайз ломает сетку 8×8.
    """
    ratio = jpeg_ratio(g)
    if ratio > JPEG_RATIO:
        return "артефакты сжатия (блоковость 8×8)", ratio
    return None, None


def scan_img_full(img):
    """Один файл -> (диагноз или None, тип контента, метрика выраженности).

    Метрика — непрерывная мера «насколько плохо» для ранжирования внутри
    категории (топ самых размытых и т.п.): blur — резкость лучшего окна
    (меньше = хуже), underexposed — средняя яркость кадра (меньше = темнее),
    overexposed — доля выбитых пикселей (больше = хуже), noise — std самых
    гладких блоков (больше = хуже), jpeg_artifacts — ds/di (больше = хуже),
    color_cast — перекос каналов (больше = хуже). У категориальных диагнозов
    (empty, screenshot, not_photo, low_res, broken) метрики нет — 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, 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, met = check_empty(g)         # 3
    if r:
        return r, media, met
    r, met = check_screenshot(g)    # 4
    if r:
        return r, media, met
    r, met = check_exposure(g, media, full_mean=g_prep.mean())  # 5
    if r:
        return r, media, met
    r, met = check_blur(g_prep, media)  # 6 — любой резкий окно во всём кадре
    if r:
        return r, media, met
    r, met = check_cast(rgb, media)  # 7
    if r:
        return r, media, met
    g_full = np.asarray(img.convert("L"), dtype=np.float32)
    r, met = check_noise(g_full, media)  # 8 — по оригиналу
    if r:
        return r, media, met
    r, met = check_jpeg(g_full)      # 9 — по оригиналу
    if r:
        return r, media, met
    return None, media, None


def scan_img(img):
    """Один файл -> (диагноз или None, тип контента). Без метрики (для старых вызовов)."""
    d, m, _ = scan_img_full(img)
    return d, m


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 scan_one_full(path):
    """scan_one + метрика выраженности."""
    try:
        img = Image.open(path)
        img.load()
    except Exception as e:
        return "битый файл: " + str(e)[:60], None, None
    return scan_img_full(img)


# метрика выраженности для ранжирования «самого плохого» внутри категории
SEVERITY = {
    "blur":           ("asc",  "резкость лучшего окна, меньше = хуже"),
    "underexposed":   ("asc",  "средняя яркость кадра, меньше = темнее"),
    "overexposed":    ("desc", "доля выбитых пикселей, больше = хуже"),
    "noise":          ("desc", "std самых гладких блоков, больше = хуже"),
    "jpeg_artifacts": ("desc", "разрыв ds/di на стыках 8×8, больше = хуже"),
    "color_cast":     ("desc", "перекос каналов на нейтральных, больше = хуже"),
}


def sort_top(folder, out=None, count=10, copy=False, jobs=1, categories=None):
    """Топ-N самых «плохих» по каждой категории (по метрике выраженности).

    Копирует/переносит изображение + парный .txt (если есть) в
    out/<категория>/, сохраняя исходные имена. categories — список категорий;
    по умолчанию шесть дефектов с метрикой (blur, underexposed, overexposed,
    noise, jpeg_artifacts, color_cast).
    """
    if categories is None:
        categories = list(SEVERITY)
    if out is None:
        out = os.path.join(os.getcwd(), os.path.basename(folder.rstrip("/")) + "_top")
    paths = []
    for root, _, files in os.walk(folder):
        for f in sorted(files):
            if os.path.splitext(f)[1].lower() in EXTS:
                paths.append(os.path.join(root, f))
    print(f"Сканирую {len(paths)} файлов...", flush=True)
    if jobs > 1 and len(paths) > 1:
        with multiprocessing.Pool(jobs) as pool:
            diags = pool.map(scan_one_full, paths, chunksize=32)
    else:
        diags = [scan_one_full(p) for p in paths]
    buckets = {}
    for p, (d, media, met) in zip(paths, diags):
        cat = category(d)
        if cat not in categories:
            continue
        buckets.setdefault(cat, []).append((met, p, d))
    os.makedirs(out, exist_ok=True)
    for cat in categories:
        items = buckets.get(cat, [])
        if cat in SEVERITY:
            direction, _ = SEVERITY[cat]
            items.sort(key=lambda x: (x[0] is None, x[0]), reverse=(direction == "desc"))
        taken = items[:count]
        cdir = os.path.join(out, cat)
        os.makedirs(cdir, exist_ok=True)
        for met, p, d in taken:
            dst = os.path.join(cdir, os.path.basename(p))
            (shutil.copy2 if copy else shutil.move)(p, dst)
            stem = os.path.splitext(p)[0]
            if os.path.exists(stem + ".txt"):
                shutil.copy2(stem + ".txt", os.path.join(cdir, os.path.splitext(os.path.basename(p))[0] + ".txt"))
        print(f"  {cat}: {len(taken)}")
    print(f"{'Скопировано' if copy else 'Перемещено'} топ-{count} по {len(categories)} категориям в {out}")


SCORE_CATS = ["blur", "underexposed", "overexposed", "noise", "jpeg_artifacts",
              "color_cast", "screenshot", "not_photo", "empty", "low_res", "good"]


def clip01(x):
    return float(min(1.0, max(0.0, x)))


def _th(v, media):
    """Порог: dict по типу контента или скаляр."""
    return v.get(media) if isinstance(v, dict) else v


def scan_scores(img):
    """Все скоры [0..1] по категориям для одного изображения.

    Скоры считаются независимо от диагноза (все метрики всегда).
    Непрерывные — нормировка метрики к порогу детектора (1.0 = дефект сильно
    выражен); категориальные (screenshot/not_photo/empty/low_res) — 1.0, если
    диагноз сработал. good — резкость, помноженная на отсутствие дефектов.
    """
    s = {c: 0.0 for c in SCORE_CATS}
    w, h = img.size
    if w * h / 1e6 < MIN_MP:
        s["low_res"] = 1.0
        return s
    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)
    d, _ = check_empty(g)
    if d:
        s["empty"] = 1.0
    d, _ = check_screenshot(g)
    if d:
        s["screenshot"] = 1.0
    d, _ = check_colors(rgb)
    if d:
        s["not_photo"] = 1.0
    best = blur_best(g_prep)
    s["blur"] = clip01((_th(BLUR_BEST, media) - best) / _th(BLUR_BEST, media))
    frame_mean = float(g_prep.mean())
    s["underexposed"] = clip01((_th(DARK_MEAN, media) - frame_mean) / _th(DARK_MEAN, media))
    s["overexposed"] = clip01(((g >= 250).mean() - EXP_FRAC) / 0.45)
    g_full = np.asarray(img.convert("L"), dtype=np.float32)
    s["noise"] = clip01((noise_s1(g_full) - _th(NOISE_STD, media)) / _th(NOISE_STD, media))
    s["jpeg_artifacts"] = clip01((jpeg_ratio(g_full) - JPEG_RATIO) / JPEG_RATIO)
    s["color_cast"] = clip01((cast_delta(rgb) - _th(CAST_DELTA, media)) / _th(CAST_DELTA, media))
    worst = max(s["blur"], s["underexposed"], s["overexposed"], s["noise"],
                s["jpeg_artifacts"], s["color_cast"], s["screenshot"],
                s["not_photo"], s["empty"])
    s["good"] = clip01(best / _th(BLUR_BEST, media)) * (1.0 - worst)
    return s


def scan_row(path):
    """Один файл -> (путь, категория, диагноз, media, скоры) или None на ошибке."""
    try:
        img = Image.open(path)
        img.load()
    except Exception as e:
        return (path, "broken", "битый файл: " + str(e)[:60], None, None)
    d, media, _ = scan_img_full(img)
    return (path, category(d), d, media, scan_scores(img))


def scores_folder(folder, out_tsv, jobs=1):
    """Просканировать папку и записать tsv со скорами по всем категориям.
    Строка на изображение: путь | категория | диагноз | media | скоры..."""
    paths = []
    for root, _, files in os.walk(folder):
        for f in sorted(files):
            if os.path.splitext(f)[1].lower() in EXTS:
                paths.append(os.path.join(root, f))
    print(f"Сканирую {len(paths)} файлов...", flush=True)
    if jobs > 1 and len(paths) > 1:
        with multiprocessing.Pool(jobs) as pool:
            rows = pool.map(scan_row, paths, chunksize=32)
    else:
        rows = [scan_row(p) for p in paths]
    with open(out_tsv, "w") as f:
        f.write("path\tcategory\tdiagnosis\tmedia\t" + "\t".join(SCORE_CATS) + "\n")
        for path, cat, diag, media, s in rows:
            if s is None:
                continue
            f.write(f"{path}\t{cat}\t{diag or ''}\t{media or ''}")
            for c in SCORE_CATS:
                f.write(f"\t{s[c]:.3f}")
            f.write("\n")
    from collections import Counter as _C
    print(f"Записано {sum(1 for r in rows if r[4] is not None)} строк в {out_tsv}")
    print("  по категориям:", dict(_C(r[1] for r in rows if r[4] is not None).most_common()))


DEFAULT_COUNTS = {"good": 1000, "blur": 1000, "underexposed": 100, "overexposed": 100,
                  "noise": 100, "jpeg_artifacts": 100, "color_cast": 100,
                  "screenshot": 100, "not_photo": 100, "empty": 100}


def select_from_tsv(tsv, out, counts=None, include_lowres=False):
    """Собрать топ-N самых «характерных» примеров по каждой категории.

    По tsv (смотри scores_folder): для категории X берутся изображения, где X —
    первичная категория, ранжируются по скору X (больше = характернее) и
    копируются с парным .txt (если есть) в out/<категория>/. По умолчанию
    low_res исключается (мелкие превью тривиально «размытые»).
    """
    if counts is None:
        counts = DEFAULT_COUNTS
    rows = []
    with open(tsv) as f:
        header = f.readline().rstrip("\n").split("\t")
        for line in f:
            parts = line.rstrip("\n").split("\t")
            if len(parts) == len(header):
                rows.append(dict(zip(header, parts)))
    print(f"Строк в tsv: {len(rows)}")
    seen_md5 = set()
    import hashlib
    selected = {}
    for cat, want in counts.items():
        pool = [r for r in rows if r["category"] == cat]
        if not include_lowres:
            pool = [r for r in pool if r.get("low_res", "0") == "0.000"]
        score_col = cat if cat in SCORE_CATS else "good"
        pool.sort(key=lambda r: float(r.get(score_col, 0)), reverse=True)
        cdir = os.path.join(out, cat)
        os.makedirs(cdir, exist_ok=True)
        taken = 0
        for r in pool:
            if taken >= want:
                break
            p = r["path"]
            if not os.path.exists(p):
                continue
            h = hashlib.md5(open(p, "rb").read()).hexdigest()
            if h in seen_md5:
                continue
            seen_md5.add(h)
            shutil.copy2(p, os.path.join(cdir, os.path.basename(p)))
            stem = os.path.splitext(p)[0]
            if os.path.exists(stem + ".txt"):
                shutil.copy2(stem + ".txt", os.path.join(cdir, os.path.splitext(os.path.basename(p))[0] + ".txt"))
            selected.setdefault(cat, []).append(r)
            taken += 1
        print(f"  {cat}: взято {taken} из {len(pool)} доступных (цель {want})")
    print(f"Готово: {out}")
    return selected


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] == "--scores":
        argv = sys.argv[2:]
        jobs = 1
        args = []
        i = 0
        while i < len(argv):
            a = argv[i]
            if a in ("-j", "--jobs"):
                jobs = int(argv[i + 1]); i += 1
            else:
                args.append(a)
            i += 1
        if len(args) < 2:
            print("Использование: scan_folder.py --scores ПАПКА OUT.tsv [-j N]")
            sys.exit(1)
        scores_folder(args[0], args[1], jobs=jobs)
        return
    if sys.argv[1] == "--select":
        argv = sys.argv[2:]
        include_lowres = "--include-lowres" in argv
        counts = None
        args = []
        i = 0
        while i < len(argv):
            a = argv[i]
            if a == "--counts":
                counts = dict(kv.split("=") for kv in argv[i + 1].split(","))
                counts = {k: int(v) for k, v in counts.items()}
                i += 1
            elif a != "--include-lowres":
                args.append(a)
            i += 1
        if len(args) < 2:
            print("Использование: scan_folder.py --select TSV OUT [--counts good=1000,blur=1000] [--include-lowres]")
            sys.exit(1)
        select_from_tsv(args[0], args[1], counts=counts, include_lowres=include_lowres)
        return
    if sys.argv[1] == "--top":
        argv = sys.argv[2:]
        copy = "--copy" in argv
        count = 10
        jobs = 1
        categories = None
        args = []
        i = 0
        while i < len(argv):
            a = argv[i]
            if a == "--count":
                count = int(argv[i + 1]); i += 1
            elif a == "--categories":
                categories = argv[i + 1].split(","); i += 1
            elif a in ("-j", "--jobs"):
                jobs = int(argv[i + 1]); i += 1
            elif a != "--copy":
                args.append(a)
            i += 1
        if not args:
            print("Использование: scan_folder.py --top ПАПКА [OUT] [--count N] [--copy] [--categories a,b,c] [-j N]")
            sys.exit(1)
        sort_top(args[0], args[1] if len(args) > 1 else None, count=count,
                 copy=copy, jobs=jobs, categories=categories)
        return
    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()