File size: 7,852 Bytes
b4f5f26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Сборка набора по классам (woman/man/cat) из ~/dataset/allphoto_train — тест анатомии.

Зачем: бабочки однородны и анатомию не показывают. Нужен набор, где модель обязана выучить
тело: разные люди, позы, ракурсы, и капшены, которые называют, кто это и что делает.

Классы: woman / man / cat — по N уникальных капшенов на класс (дедуп по первым 60 символам,
иначе один photoshoot даёт десятки почти одинаковых кадров).

  python3 src/make_dataset_people.py --n 60 --out ~/dataset/testset_anatomy
"""
from __future__ import annotations

import argparse
import csv
import math
import random
import re
import shutil
from pathlib import Path

from PIL import Image, ImageOps

SRC = Path.home() / "dataset" / "allphoto_train"

CLASSES = {
    "woman": ("woman", "women", "girl"),
    "man": ("man", "men", "boy"),
    "cat": ("cat",),
}
# бакеты как в moss/src/make_dataset.py: обе стороны кратны 16 (латент /8, потом patch-2),
# соотношение выбирается ближайшим по |log(ratio_источника / ratio_бакета)|, затем ЦЕНТР-КРОП —
# растягивать нельзя, иначе лица и тела деформируются
def buckets(long_side: int = 512, multiple: int = 64) -> dict:
    """Бакеты: длинная сторона long_side, обе стороны кратны 16 (латент /8, затем /2)."""
    L = long_side
    def r16(x): return max(multiple, int(round(x / multiple)) * multiple)
    return {
        "1:1": (L, L),
        "4:3": (L, r16(L * 3 / 4)),
        "3:4": (r16(L * 3 / 4), L),
        "3:2": (L, r16(L * 2 / 3)),
        "2:3": (r16(L * 2 / 3), L),
        "16:9": (L, r16(L * 9 / 16)),
        "9:16": (r16(L * 9 / 16), L),
    }
BUCKETS = buckets(512)

# для людей требуем, чтобы в кадре было тело, а не только лицо: бабочки-портреты анатомию не учат
BODY_WORDS = ("standing", "walking", "sitting", "seated", "jeans", "shorts", "dress", "skirt",
              "shirt", "legs", "arms", "hands", "wearing", "full body", "walks", "stands")


def classify(cap: str) -> str | None:
    low = cap.lower()
    for cls, words in CLASSES.items():
        if any(re.search(rf"\b{w}\b", low) for w in words):
            # «man» внутри «woman» уже отсечён \b; но «woman» не должна становиться «man»
            if cls == "man" and re.search(r"\bwoman\b", low):
                return "woman"
            return cls
    return None


def trim_black(im: Image.Image, thr: float = 12.0, var: float = 8.0) -> Image.Image:
    """Срезает однородные чёрные поля по краям (в allphoto_train их полно: 71 из 171 кадра)."""
    import numpy as np
    a = np.asarray(im.convert("L"), dtype=np.float32)
    def uniform_edge(line):        # чёрная и без структуры -> рамка, а не ночная сцена
        return line.mean() < thr and line.std() < var
    top, bot, left, right = 0, a.shape[0], 0, a.shape[1]
    while top < bot - 8 and uniform_edge(a[top]): top += 1
    while bot > top + 8 and uniform_edge(a[bot - 1]): bot -= 1
    while left < right - 8 and uniform_edge(a[top:bot, left]): left += 1
    while right > left + 8 and uniform_edge(a[top:bot, right - 1]): right -= 1
    return im.crop((left, top, right, bot)) if (left or top or right != a.shape[1] or bot != a.shape[0]) else im


def plan_size(w: int, h: int, max_upscale: float = 1.4):
    """(w,h) -> (label, tw, th, cw, ch) или None, если источника не хватает (как в moss)."""
    ratio = w / h
    label, (tw, th) = min(BUCKETS.items(), key=lambda kv: abs(math.log(ratio / (kv[1][0] / kv[1][1]))))
    if ratio > tw / th:
        cw, ch = round(h * tw / th), h
    else:
        cw, ch = w, round(w * th / tw)
    cw, ch = min(cw, w), min(ch, h)
    if cw * max_upscale < tw or ch * max_upscale < th:
        return None
    return label, tw, th, cw, ch


def load_bucketed(path: Path, max_upscale: float = 1.4):
    """EXIF-поворот -> срез чёрных полей -> ближайший бакет -> центр-кроп -> ресайз."""
    im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
    im = trim_black(im)
    plan = plan_size(im.width, im.height, max_upscale)
    if plan is None:
        return None, None
    label, tw, th, cw, ch = plan
    left, top = (im.width - cw) // 2, (im.height - ch) // 2
    return im.crop((left, top, left + cw, top + ch)).resize((tw, th), Image.LANCZOS), label


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--n", type=int, default=60, help="картинок на класс")
    ap.add_argument("--eval-n", type=int, default=4, help="сколько на класс отложить для показа")
    ap.add_argument("--max-upscale", type=float, default=1.4)
    ap.add_argument("--long-side", type=int, default=512)
    ap.add_argument("--multiple", type=int, default=64)
    ap.add_argument("--seed", type=int, default=0)
    ap.add_argument("--out", default=str(Path.home() / "dataset" / "testset_anatomy"))
    args = ap.parse_args()

    global BUCKETS
    BUCKETS = buckets(args.long_side, args.multiple)
    out = Path(args.out)
    for sub in ("train", "eval"):
        (out / sub).mkdir(parents=True, exist_ok=True)

    caps = sorted(SRC.glob("*.txt"))
    rng = random.Random(args.seed)
    rng.shuffle(caps)

    seen_prefix: set[str] = set()
    picked: dict[str, list[tuple[Path, str]]] = {c: [] for c in CLASSES}
    need = args.n + args.eval_n
    for f in caps:
        cap = " ".join(f.read_text().split())
        if len(cap) < 40:
            continue
        cls = classify(cap)
        if cls is None or len(picked[cls]) >= need:
            continue
        low = cap.lower()
        if cls == "cat" and re.search(r"\b(dog|puppy|kitten|rabbit)\b", low) and "cat" not in low:
            continue
        if cls in ("woman", "man") and not any(w in low for w in BODY_WORDS):
            continue
        key = cap[:60].lower()
        if key in seen_prefix:
            continue
        img = f.with_suffix(".jpg")
        if not img.exists():
            continue
        seen_prefix.add(key)
        picked[cls].append((img, cap))
        if all(len(v) >= need for v in picked.values()):
            break

    rows = []
    for cls, items in picked.items():
        for i, (img, cap) in enumerate(items):
            split = "eval" if i < args.eval_n else "train"
            im, bucket = load_bucketed(img)
            if im is None:
                continue
            name = f"{cls}_{i:03d}"
            im.save(out / split / f"{name}.jpg", quality=92)
            (out / split / f"{name}.txt").write_text(cap + "\n")
            rows.append({"split": split, "class": cls, "name": name, "w": im.width, "h": im.height,
                         "bucket": bucket, "caption": cap})
            print(f"  {split}/{name} {im.width}x{im.height} | {cap[:80]}")

    with open(out / "index.csv", "w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["split", "class", "name", "w", "h", "bucket", "caption"])
        w.writeheader(); w.writerows(rows)
    per = {c: sum(1 for r in rows if r["class"] == c and r["split"] == "train") for c in CLASSES}
    print(f"\nитого: {sum(per.values())} train {per} + {sum(1 for r in rows if r['split']=='eval')} eval"
          f" -> {out}")


if __name__ == "__main__":
    main()