smartvision-ai / src /data /loader.py
Priya Monisha
fix: pass ruff lint+format, clean README, remove stray script
fa0ce5b
Raw History Blame Contribute Delete
11 kB
# 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"