File size: 10,956 Bytes
9b0ae38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
9b0ae38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
 
 
1a28594
 
9b0ae38
 
 
 
fa0ce5b
9b0ae38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
9b0ae38
fa0ce5b
 
 
 
 
 
9b0ae38
 
 
 
 
fa0ce5b
 
9b0ae38
 
 
fa0ce5b
 
9b0ae38
 
 
1a28594
 
fa0ce5b
1a28594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
 
 
 
1a28594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
1a28594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b0ae38
 
fa0ce5b
9b0ae38
 
 
 
 
 
 
 
1a28594
 
 
 
 
 
 
 
 
 
9b0ae38
 
 
fa0ce5b
9b0ae38
 
 
1a28594
 
 
 
 
9b0ae38
 
 
 
fa0ce5b
9b0ae38
 
 
 
 
 
 
 
 
 
 
fa0ce5b
9b0ae38
 
 
 
 
 
 
 
 
fa0ce5b
 
9b0ae38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa0ce5b
9b0ae38
 
 
fa0ce5b
9b0ae38
 
 
 
 
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
# filename: src/data/loader.py
# purpose:  HuggingFace COCO streaming, collection, and dataset saving pipeline
# version:  2.0

# SCHEMA NOTES (detection-datasets/coco):
#   sample["image"]                β†’ PIL.Image (direct, not bytes dict)
#   sample["objects"]["bbox"]      β†’ list of [x, y, w, h] (top-left + width/height, absolute pixels)
#   sample["objects"]["category"]  β†’ list of 0-indexed HF category IDs (0-79, NOT COCO IDs 1-90)
#   No trust_remote_code needed. No HF token required for public dataset.
#   bbox format confirmed via mentor notebook: crop((x, y, x+w, y+h)) produces correct results.

import logging
from pathlib import Path

from PIL import Image

from config import (
    CLASSES,
    CHECKPOINT_FILE,
    HF_CATEGORY_TO_CLASS_IDX,
    NUM_CLASSES,
    SELECTED_CLASSES,
    TRAIN_SPLIT,
    VAL_SPLIT,
)
from src.utils.helpers import load_json, save_json

logger = logging.getLogger(__name__)


# ── COCO mapping verification ─────────────────────────────────────────────────
# Rule 28: call this as the first thing in 01_data_acquisition.py


def verify_coco_mapping() -> None:
    """Verify SELECTED_CLASSES and HF_CATEGORY_TO_CLASS_IDX are consistent."""
    assert len(SELECTED_CLASSES) == NUM_CLASSES, (
        f"SELECTED_CLASSES has {len(SELECTED_CLASSES)} entries, expected {NUM_CLASSES}"
    )
    assert len(HF_CATEGORY_TO_CLASS_IDX) == NUM_CLASSES, (
        f"HF_CATEGORY_TO_CLASS_IDX has {len(HF_CATEGORY_TO_CLASS_IDX)} entries, expected {NUM_CLASSES}"
    )
    # Check round-trip: class_name β†’ hf_id β†’ class_idx β†’ class_name
    for cls_name, hf_id in SELECTED_CLASSES.items():
        assert hf_id in HF_CATEGORY_TO_CLASS_IDX, (
            f"HF ID {hf_id} (for '{cls_name}') missing from HF_CATEGORY_TO_CLASS_IDX"
        )
        class_idx = HF_CATEGORY_TO_CLASS_IDX[hf_id]
        assert CLASSES[class_idx] == cls_name, (
            f"Round-trip mismatch: '{cls_name}' β†’ hf_id={hf_id} β†’ idx={class_idx} β†’ '{CLASSES[class_idx]}'"
        )
    # No duplicate HF IDs
    hf_ids = list(SELECTED_CLASSES.values())
    assert len(hf_ids) == len(set(hf_ids)), (
        "Duplicate HF category IDs in SELECTED_CLASSES"
    )
    logger.info(f"COCO mapping verified: {NUM_CLASSES} classes, no duplicates")
    print(f"COCO mapping OK: {NUM_CLASSES} classes, HF 0-indexed IDs, no duplicates")


# ── Checkpoint / resume ───────────────────────────────────────────────────────


def load_checkpoint() -> dict[str, int]:
    if CHECKPOINT_FILE.exists():
        data = load_json(CHECKPOINT_FILE)
        total = sum(data.values())
        logger.info(f"Resumed from checkpoint: {total} images already collected")
        return data
    return {cls: 0 for cls in CLASSES}


def save_checkpoint(progress: dict[str, int]) -> None:
    CHECKPOINT_FILE.parent.mkdir(parents=True, exist_ok=True)
    save_json(progress, CHECKPOINT_FILE)


# ── YOLO bbox conversion ──────────────────────────────────────────────────────
# bbox in HF dataset: [x, y, w, h] β€” top-left corner + width/height (absolute pixels)
# Confirmed by mentor notebook: crop((x, y, x+w, y+h)) produces correct object crops


def bbox_to_yolo(
    x: float,
    y: float,
    w: float,
    h: float,
    img_w: int,
    img_h: int,
    class_idx: int,
) -> list[float]:
    """Convert [x, y, w, h] absolute pixels β†’ YOLO [class, x_center, y_center, w, h] normalized."""
    x_center = (x + w / 2.0) / img_w
    y_center = (y + h / 2.0) / img_h
    w_norm = w / img_w
    h_norm = h / img_h
    # Clamp to valid range
    x_center = max(0.0, min(1.0, x_center))
    y_center = max(0.0, min(1.0, y_center))
    w_norm = max(0.001, min(1.0, w_norm))
    h_norm = max(0.001, min(1.0, h_norm))
    return [class_idx, x_center, y_center, w_norm, h_norm]


# ── Overlap utilities ─────────────────────────────────────────────────────────


def compute_overlap_ratio(target_box: list, other_box: list) -> float:
    """
    Fraction of target_box area covered by other_box.
    Expects [x, y, w, h] format (top-left corner + dimensions).

    Use instead of IoU when checking whether a large object (person) dominates
    a smaller object (chair). IoU penalises the large union area and under-detects
    containment when other_box >> target_box.

    Returns float in [0, 1].
    """
    assert target_box[2] > 0 and target_box[3] > 0, (
        f"target_box width/height must be positive: {target_box}. "
        "Ensure bbox format is [x, y, w, h], not [x1, y1, x2, y2]."
    )
    assert other_box[2] > 0 and other_box[3] > 0, (
        f"other_box width/height must be positive: {other_box}. "
        "Ensure bbox format is [x, y, w, h], not [x1, y1, x2, y2]."
    )
    tx1, ty1 = target_box[0], target_box[1]
    tx2, ty2 = target_box[0] + target_box[2], target_box[1] + target_box[3]
    ox1, oy1 = other_box[0], other_box[1]
    ox2, oy2 = other_box[0] + other_box[2], other_box[1] + other_box[3]

    inter_x1 = max(tx1, ox1)
    inter_y1 = max(ty1, oy1)
    inter_x2 = min(tx2, ox2)
    inter_y2 = min(ty2, oy2)
    inter_area = max(0, inter_x2 - inter_x1) * max(0, inter_y2 - inter_y1)

    target_area = target_box[2] * target_box[3]
    return inter_area / target_area if target_area > 0 else 0.0


def check_person_overlap(
    target_bbox: list,
    all_bboxes: list,
    all_cat_ids: list,
    person_cat_id: int,
    threshold: float = 0.50,
) -> bool:
    """
    Return True if any person bbox covers > threshold fraction of target_bbox area.

    Uses compute_overlap_ratio, NOT IoU. IoU under-detects containment when
    person bbox is larger than chair bbox (the common COCO case).

    person_cat_id: must match the ID system in all_cat_ids.
    In this project: all_cat_ids contains HF 0-indexed IDs (loader.py line 8).
    Pass SELECTED_CLASSES["person"] = 0.

    threshold: 0.50 default (conservative). Adjust to 0.30 in a second
    re-collection run if chair accuracy remains low after first run.
    """
    for bbox, cat_id in zip(all_bboxes, all_cat_ids):
        if cat_id != person_cat_id:
            continue
        if compute_overlap_ratio(target_bbox, bbox) > threshold:
            return True
    return False


# ── Crop quality gates ────────────────────────────────────────────────────────


def check_crop_quality(img: Image.Image, x1: int, y1: int, x2: int, y2: int) -> str:
    """Return 'ok' or a rejection reason string.

    Gates (all three must pass):
      too_small  : crop < 48Γ—48px in original image (upscale > 4.7Γ— β†’ blurry)
      too_tiny   : bbox covers < 1% of image area (invisible/background object)
      bad_aspect : longer side > 5Γ— shorter side (sliver or letterbox crop)
    """
    crop_w = x2 - x1
    crop_h = y2 - y1
    if crop_w < 80 or crop_h < 80:
        return "too_small"
    if (crop_w * crop_h) / (img.width * img.height) < 0.02:
        return "too_tiny"
    if max(crop_w, crop_h) / min(crop_w, crop_h) > 5.0:
        return "bad_aspect"
    return "ok"


# ── Crop and save classification image ───────────────────────────────────────


def save_classification_crop(
    img: Image.Image,
    bbox: list[float],
    class_name: str,
    split: str,
    img_idx: int,
    classification_dir: Path,
    crop_size: int = 224,
) -> bool | str:
    """Crop object bbox, apply quality gates, resize to 224Γ—224, save.

    Returns:
        True          β€” saved successfully
        "too_small"   β€” crop < 48px in either dimension
        "too_tiny"    β€” bbox covers < 1% of image area
        "bad_aspect"  β€” aspect ratio > 5:1
        False         β€” unexpected save error
    """
    x, y, w, h = bbox
    x1 = max(0, int(x))
    y1 = max(0, int(y))
    x2 = min(img.width, int(x + w))
    y2 = min(img.height, int(y + h))

    if x2 <= x1 or y2 <= y1:
        return "too_small"

    quality = check_crop_quality(img, x1, y1, x2, y2)
    if quality != "ok":
        return quality

    try:
        crop = img.crop((x1, y1, x2, y2))
        crop = crop.resize((crop_size, crop_size), Image.LANCZOS)
        out_dir = classification_dir / split / class_name
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path = out_dir / f"{class_name}_{split}_{img_idx:04d}.jpg"
        crop.save(str(out_path), "JPEG", quality=95)
        return True
    except Exception as e:
        logger.warning(f"Crop failed for {class_name} idx={img_idx}: {e}")
        return False


# ── Save detection sample ─────────────────────────────────────────────────────


def save_detection_sample(
    img: Image.Image,
    annotations: dict,
    img_id: int,
    split: str,
    detection_dir: Path,
) -> None:
    """Save full image and YOLO-format .txt label for detection task."""
    img_w, img_h = img.size
    bboxes = annotations["bbox"]
    cat_ids = annotations["category"]

    # Build YOLO annotation lines for our 25 classes only
    yolo_lines = []
    for bbox, cat_id in zip(bboxes, cat_ids):
        if cat_id not in HF_CATEGORY_TO_CLASS_IDX:
            continue
        class_idx = HF_CATEGORY_TO_CLASS_IDX[cat_id]
        x, y, w, h = bbox
        vals = bbox_to_yolo(x, y, w, h, img_w, img_h, class_idx)
        yolo_lines.append(
            f"{int(vals[0])} {vals[1]:.6f} {vals[2]:.6f} {vals[3]:.6f} {vals[4]:.6f}"
        )

    if not yolo_lines:
        return

    img_dir = detection_dir / "images" / split
    lbl_dir = detection_dir / "labels" / split
    img_dir.mkdir(parents=True, exist_ok=True)
    lbl_dir.mkdir(parents=True, exist_ok=True)

    img.save(str(img_dir / f"image_{img_id:06d}.jpg"), "JPEG", quality=95)
    (lbl_dir / f"image_{img_id:06d}.txt").write_text(
        "\n".join(yolo_lines), encoding="utf-8"
    )


# ── Split helper ──────────────────────────────────────────────────────────────


def get_split(img_idx: int, total: int) -> str:
    """Return 'train', 'val', or 'test' based on img_idx position."""
    train_end = int(total * TRAIN_SPLIT)
    val_end = train_end + int(total * VAL_SPLIT)
    if img_idx < train_end:
        return "train"
    elif img_idx < val_end:
        return "val"
    return "test"