aifh / object_detection /pre_process.py
Ishaank18's picture
Upload via upload_to_hf.py
d0518d9 verified
Raw History Blame Contribute Delete
19.7 kB
"""
pre_process.py -- Part B, Sections 3.1 and 3.2
(Data preparation, annotation checks, and exploratory analysis)
1. Loads Kvasir-SEG images, masks and the bounding-box json, and converts the
annotations into the xyxy + label format Faster R-CNN expects.
2. Annotation QC: images with no box, boxes outside the image, degenerate
(zero/negative area) boxes, box-vs-mask agreement (IoU between the annotated
box and the tight bounding box of the segmentation mask), and json/image
size disagreements.
3. Frozen stratified split with SEED = 16 -> splits/kvasir_detection_seed16.csv
4. EDA: box count per image, box width/height, box-area fraction, aspect ratio,
polyp centre position, image-size distribution, per-split distribution.
5. At least 8 seed-selected image + mask + box overlays.
Usage
-----
python pre_process.py
python pre_process.py --data-root ../Dataset/kvasir/Kvasir-SEG --n-examples 8
"""
from __future__ import annotations
import argparse
import json
from collections import Counter
from pathlib import Path
import numpy as np
import pandas as pd
from common import (DET_SPLIT_CSV, boxplot, METRICS_DIR, OVERLAY_DIR, PLOTS_DIR, SEED,
SEED_TAG, SPLIT_FRACTIONS, _mpl, box_iou_np, find_bbox_json,
find_kvasir_root, load_bboxes, progress, save_json,
seeded_rng, set_seed)
IMG_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".tif"}
# ---------------------------------------------------------------------------
def mask_bbox(mask: np.ndarray) -> list | None:
"""Tight xyxy box of the (largest connected component of the) mask."""
b = mask > 0.5
if b.sum() == 0:
return None
ys, xs = np.where(b)
return [float(xs.min()), float(ys.min()), float(xs.max() + 1), float(ys.max() + 1)]
def build_index(root: Path, bbox_json: Path) -> tuple[pd.DataFrame, dict]:
from PIL import Image
ann = load_bboxes(bbox_json)
img_dir, mask_dir = root / "images", root / "masks"
images = sorted(p for p in img_dir.iterdir() if p.suffix.lower() in IMG_EXT)
rows, issues = [], {
"images_without_annotation": [], "annotations_without_image": [],
"images_with_zero_boxes": [], "degenerate_boxes": [],
"boxes_out_of_bounds": [], "json_size_mismatch": [],
"missing_masks": [], "empty_masks": [], "low_box_mask_iou": [],
}
ann_keys = set(ann.keys())
for ip in progress(images, desc="index kvasir", total=len(images)):
stem = ip.stem
with Image.open(ip) as im:
W, H = im.size
mp = mask_dir / f"{stem}{ip.suffix}"
if not mp.exists():
for e in (".jpg", ".png", ".jpeg"):
if (mask_dir / f"{stem}{e}").exists():
mp = mask_dir / f"{stem}{e}"
break
has_mask = mp.exists()
if not has_mask:
issues["missing_masks"].append(stem)
a = ann.get(stem)
if a is None:
issues["images_without_annotation"].append(stem)
boxes = []
else:
ann_keys.discard(stem)
boxes = a["boxes"]
if a.get("width") and (int(a["width"]) != W or int(a["height"]) != H):
issues["json_size_mismatch"].append(
{"image_id": stem, "json": [a.get("width"), a.get("height")],
"file": [W, H]})
clean = []
for b in boxes:
x1, y1, x2, y2 = b
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
if (x2 - x1) < 1 or (y2 - y1) < 1:
issues["degenerate_boxes"].append({"image_id": stem, "box": b})
continue
if x1 < 0 or y1 < 0 or x2 > W or y2 > H:
issues["boxes_out_of_bounds"].append(
{"image_id": stem, "box": [x1, y1, x2, y2], "image_size": [W, H]})
x1, y1 = max(0.0, x1), max(0.0, y1)
x2, y2 = min(float(W), x2), min(float(H), y2)
if (x2 - x1) < 1 or (y2 - y1) < 1:
continue
clean.append([x1, y1, x2, y2])
if not clean:
issues["images_with_zero_boxes"].append(stem)
# --- box vs mask agreement -----------------------------------------
box_mask_iou, mask_area_frac = np.nan, np.nan
if has_mask:
m = np.asarray(Image.open(mp).convert("L"), dtype=np.float32) / 255.0
if m.shape[:2] != (H, W):
from PIL import Image as _I
m = np.asarray(_I.open(mp).convert("L").resize((W, H), _I.NEAREST),
dtype=np.float32) / 255.0
mb = mask_bbox(m)
mask_area_frac = float((m > 0.5).mean())
if mb is None:
issues["empty_masks"].append(stem)
elif clean:
box_mask_iou = float(box_iou_np(np.array(clean, np.float32),
np.array([mb], np.float32)).max())
if box_mask_iou < 0.5:
issues["low_box_mask_iou"].append(
{"image_id": stem, "iou": round(box_mask_iou, 3)})
areas = [(b[2] - b[0]) * (b[3] - b[1]) for b in clean]
rows.append({
"image_id": stem,
"image_path": str(ip.resolve()),
"mask_path": str(mp.resolve()) if has_mask else "",
"width": W, "height": H, "image_area": W * H,
"n_boxes": len(clean),
"boxes": json.dumps([[round(v, 2) for v in b] for b in clean]),
"total_box_area_frac": float(sum(areas) / (W * H)) if areas else 0.0,
"max_box_area_frac": float(max(areas) / (W * H)) if areas else 0.0,
"mask_area_frac": mask_area_frac,
"box_mask_iou": box_mask_iou,
})
issues["annotations_without_image"] = sorted(ann_keys)
return pd.DataFrame(rows), issues
# ---------------------------------------------------------------------------
def _collapse_rare(strat: "pd.Series", min_count: int = 2) -> "pd.Series":
"""Merge any stratum below `min_count` members into a single 'rare' bucket.
sklearn's stratified split requires at least 2 members per class, so this
must be re-applied to whatever subset is about to be split -- a stratum
that clears the threshold in the full dataset can still drop below it
once a 70/30 (or 15/15) subset is drawn from it."""
vc = strat.value_counts()
return strat.where(strat.map(vc) >= min_count, "rare")
def make_splits(df: pd.DataFrame, seed: int = SEED, fracs=SPLIT_FRACTIONS) -> pd.DataFrame:
"""Stratified on a size bucket derived from the largest box, so that small,
medium and large polyps are represented in every split (Kvasir-SEG has no
class label to stratify on -- polyp size is the meaningful axis)."""
from sklearn.model_selection import train_test_split
d = df.copy().reset_index(drop=True)
d["size_bucket"] = pd.cut(d["max_box_area_frac"],
bins=[-0.001, 0.01, 0.05, 0.20, 1.01],
labels=["tiny", "small", "medium", "large"]).astype(str)
d.loc[d["n_boxes"] == 0, "size_bucket"] = "empty"
d["strat"] = d["size_bucket"] + "_" + (d["n_boxes"] > 1).map({True: "multi", False: "single"})
# Collapse rare strata for the first split (70 / 30).
strat1 = _collapse_rare(d["strat"], min_count=6)
try:
tr, tmp = train_test_split(d, train_size=fracs[0], random_state=seed,
stratify=strat1, shuffle=True)
except ValueError:
# even the collapsed bucketing has a class with < 2 members overall;
# fall back to an unstratified split rather than crashing.
print("[warn] falling back to an unstratified train split: a size "
"bucket has fewer than 2 images even after collapsing rare "
"buckets together")
tr, tmp = train_test_split(d, train_size=fracs[0], random_state=seed, shuffle=True)
# Re-collapse against the smaller `tmp` subset before the second split --
# a stratum with 8 members overall can easily have only 1 in a 30% sample.
rel_val = fracs[1] / (fracs[1] + fracs[2])
strat2 = _collapse_rare(tmp["strat"], min_count=2)
try:
va, te = train_test_split(tmp, train_size=rel_val, random_state=seed,
stratify=strat2, shuffle=True)
except ValueError:
print("[warn] falling back to an unstratified val/test split for the "
"same reason")
va, te = train_test_split(tmp, train_size=rel_val, random_state=seed, shuffle=True)
d["split"] = ""
d.loc[tr.index, "split"] = "train"
d.loc[va.index, "split"] = "val"
d.loc[te.index, "split"] = "test"
return d
# ---------------------------------------------------------------------------
def eda(d: pd.DataFrame, out_dir: Path = PLOTS_DIR) -> dict:
plt = _mpl()
stats: dict = {}
boxes, wfrac, hfrac, afrac, ar, cx, cy, splits = [], [], [], [], [], [], [], []
for _, r in d.iterrows():
for b in json.loads(r["boxes"]):
x1, y1, x2, y2 = b
w, h = x2 - x1, y2 - y1
boxes.append([w, h])
wfrac.append(w / r["width"]); hfrac.append(h / r["height"])
afrac.append(w * h / r["image_area"]); ar.append(w / max(1e-6, h))
cx.append((x1 + x2) / 2 / r["width"]); cy.append((y1 + y2) / 2 / r["height"])
splits.append(r["split"])
boxes = np.array(boxes) if boxes else np.zeros((0, 2))
afrac = np.array(afrac); ar = np.array(ar)
# ---- figure 1: counts and sizes ----
fig, axes = plt.subplots(2, 3, figsize=(15, 8))
cnt = d["n_boxes"].value_counts().sort_index()
axes[0, 0].bar(cnt.index.astype(str), cnt.values, color="#4C72B0")
axes[0, 0].set_title("Bounding boxes per image"); axes[0, 0].set_xlabel("n boxes")
for i, v in enumerate(cnt.values):
axes[0, 0].text(i, v, str(v), ha="center", va="bottom", fontsize=8)
sz = d.groupby(["width", "height"]).size().sort_values(ascending=False).head(10)
axes[0, 1].bar([f"{w}x{h}" for w, h in sz.index], sz.values, color="#55A868")
axes[0, 1].set_title("Top-10 image resolutions"); axes[0, 1].set_yscale("log")
plt.setp(axes[0, 1].get_xticklabels(), rotation=40, ha="right", fontsize=7)
axes[0, 2].hist(afrac, bins=50, color="#C44E52")
axes[0, 2].set_title("Box area fraction of image"); axes[0, 2].set_xlabel("area fraction")
axes[0, 2].axvline(0.01, ls="--", c="k", lw=1)
axes[0, 2].axvline(0.05, ls="--", c="k", lw=1)
axes[0, 2].text(0.011, axes[0, 2].get_ylim()[1] * 0.8, " small | medium | large", fontsize=7)
if len(boxes):
axes[1, 0].scatter(boxes[:, 0], boxes[:, 1], s=6, alpha=0.35, color="#8172B2")
axes[1, 0].set_xlabel("box width (px)"); axes[1, 0].set_ylabel("box height (px)")
axes[1, 0].set_title("Box width vs height")
axes[1, 1].hist(np.clip(ar, 0, 4), bins=50, color="#937860")
axes[1, 1].set_title("Aspect ratio (w/h)"); axes[1, 1].axvline(1.0, ls="--", c="k", lw=1)
hb = axes[1, 2].hist2d(cx, cy, bins=20, range=[[0, 1], [0, 1]], cmap="magma")
axes[1, 2].invert_yaxis()
axes[1, 2].set_title("Polyp centre position (normalised)")
fig.colorbar(hb[3], ax=axes[1, 2], fraction=0.046)
fig.suptitle(f"Kvasir-SEG exploratory analysis (seed={SEED})", fontsize=13)
fig.tight_layout()
fig.savefig(out_dir / f"B1_kvasir_eda_{SEED_TAG}.png", dpi=155); plt.close(fig)
# ---- figure 2: split distribution ----
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))
piv = (d.pivot_table(index="size_bucket", columns="split", values="image_id",
aggfunc="count", fill_value=0)
.reindex(columns=["train", "val", "test"], fill_value=0))
piv.plot(kind="bar", stacked=True, ax=axes[0], colormap="viridis")
axes[0].set_title("Polyp size bucket per split"); axes[0].set_ylabel("images")
plt.setp(axes[0].get_xticklabels(), rotation=0)
sdf = pd.DataFrame({"split": splits, "area_frac": afrac})
boxplot(axes[1], [sdf[sdf.split == s]["area_frac"].values for s in ["train", "val", "test"]],
["train", "val", "test"], showfliers=False)
axes[1].set_title("Box area fraction per split")
fig.tight_layout()
fig.savefig(out_dir / f"B2_kvasir_split_distribution_{SEED_TAG}.png", dpi=155); plt.close(fig)
stats["n_images"] = int(len(d))
stats["n_boxes_total"] = int(d["n_boxes"].sum())
stats["boxes_per_image"] = {str(k): int(v) for k, v in cnt.items()}
stats["mean_boxes_per_image"] = float(d["n_boxes"].mean())
stats["n_images_with_zero_boxes"] = int((d["n_boxes"] == 0).sum())
stats["n_images_multi_box"] = int((d["n_boxes"] > 1).sum())
stats["image_resolutions_top10"] = {f"{w}x{h}": int(v) for (w, h), v in sz.items()}
stats["n_distinct_resolutions"] = int(d.groupby(["width", "height"]).ngroups)
stats["box_width_px"] = _desc(boxes[:, 0] if len(boxes) else np.zeros(0))
stats["box_height_px"] = _desc(boxes[:, 1] if len(boxes) else np.zeros(0))
stats["box_area_fraction"] = _desc(afrac)
stats["aspect_ratio"] = _desc(ar)
stats["centre_x"] = _desc(np.array(cx)); stats["centre_y"] = _desc(np.array(cy))
stats["size_buckets"] = {
"small_lt_1pct": int((afrac < 0.01).sum()),
"medium_1_to_5pct": int(((afrac >= 0.01) & (afrac < 0.05)).sum()),
"large_ge_5pct": int((afrac >= 0.05).sum())}
stats["split_counts"] = d["split"].value_counts().to_dict()
stats["split_box_counts"] = d.groupby("split")["n_boxes"].sum().to_dict()
stats["size_bucket_by_split"] = piv.to_dict()
stats["foreground_pixel_fraction_mean"] = float(d["mask_area_frac"].dropna().mean())
stats["class_imbalance_note"] = (
"Kvasir-SEG is single-class, so 'imbalance' in detection is the "
"foreground/background imbalance: on average only "
f"{100 * float(np.mean(afrac)):.1f}% of an image is inside a polyp box, and the "
"detector scores thousands of mostly-background anchors/proposals per image. "
"This is precisely the imbalance the focal-loss ablation targets.")
return stats
def _desc(a: np.ndarray) -> dict:
if a.size == 0:
return {}
return {"mean": float(a.mean()), "std": float(a.std()), "min": float(a.min()),
"p05": float(np.percentile(a, 5)), "median": float(np.median(a)),
"p95": float(np.percentile(a, 95)), "max": float(a.max())}
# ---------------------------------------------------------------------------
def qualitative(d: pd.DataFrame, n: int = 8, out_dir: Path = OVERLAY_DIR):
"""Seed-selected image / mask / box overlays spanning the size buckets."""
plt = _mpl()
from PIL import Image
rng = seeded_rng(offset=5)
picks = []
for bucket in ["tiny", "small", "medium", "large"]:
sub = d[d["size_bucket"] == bucket]
if len(sub) == 0:
continue
k = min(max(1, n // 4), len(sub))
idx = rng.choice(len(sub), size=k, replace=False)
picks.append(sub.iloc[idx])
sel = pd.concat(picks).head(n) if picks else d.head(n)
if len(sel) < n:
extra = d[~d["image_id"].isin(sel["image_id"])].head(n - len(sel))
sel = pd.concat([sel, extra])
ncol = 4
nrow = int(np.ceil(len(sel) / ncol))
fig, axes = plt.subplots(nrow, ncol, figsize=(4.0 * ncol, 4.3 * nrow))
axes = np.atleast_1d(axes).ravel()
for k, (_, r) in enumerate(sel.iterrows()):
img = np.asarray(Image.open(r["image_path"]).convert("RGB"), np.float32) / 255.0
if r["mask_path"]:
m = np.asarray(Image.open(r["mask_path"]).convert("L")
.resize((r["width"], r["height"]), Image.NEAREST), np.float32) / 255.0
img[..., 1] = np.where(m > 0.5, 0.55 * img[..., 1] + 0.45, img[..., 1])
axes[k].imshow(np.clip(img, 0, 1))
for b in json.loads(r["boxes"]):
x1, y1, x2, y2 = b
axes[k].add_patch(plt.Rectangle((x1, y1), x2 - x1, y2 - y1, fill=False,
edgecolor="red", lw=2.0))
axes[k].set_title(f"{r['image_id'][:16]}...\n{r['width']}x{r['height']} | "
f"{r['n_boxes']} box | area={r['max_box_area_frac']:.3f} | "
f"{r['split']}", fontsize=7.5)
axes[k].axis("off")
for k in range(len(sel), len(axes)):
axes[k].axis("off")
fig.suptitle(f"Kvasir-SEG: image + mask (green) + box (red), seed={SEED}", fontsize=12)
fig.tight_layout()
out = out_dir / f"B3_kvasir_annotations_{SEED_TAG}.png"
fig.savefig(out, dpi=150); plt.close(fig)
print(f"[saved] {out}")
return sel[["image_id", "split", "width", "height", "n_boxes", "max_box_area_frac"]]
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Part B data prep + EDA (seed 16)")
ap.add_argument("--data-root", default=None)
ap.add_argument("--bbox-json", default=None)
ap.add_argument("--n-examples", type=int, default=8)
args = ap.parse_args()
set_seed(SEED)
root = find_kvasir_root(args.data_root)
bj = Path(args.bbox_json) if args.bbox_json else find_bbox_json(root)
print(f"[info] kvasir root : {root}")
print(f"[info] bbox json : {bj}")
print(f"[info] seed : {SEED}")
df, issues = build_index(root, bj)
d = make_splits(df, seed=SEED)
d.to_csv(DET_SPLIT_CSV, index=False)
print(f"[saved] {DET_SPLIT_CSV}")
print("\n=== Split counts (seed=%d) ===" % SEED)
tab = d.groupby("split").agg(images=("image_id", "count"), boxes=("n_boxes", "sum"))
tab = tab.reindex(["train", "val", "test"])
tab["mean_boxes_per_image"] = (tab["boxes"] / tab["images"]).round(3)
print(tab.to_string())
stats = eda(d)
audit = {"seed": SEED, "dataset_root": str(root), "bbox_json": str(bj),
"n_images": int(len(d)),
"n_images_without_annotation": len(issues["images_without_annotation"]),
"n_annotations_without_image": len(issues["annotations_without_image"]),
"n_images_with_zero_boxes": len(issues["images_with_zero_boxes"]),
"n_degenerate_boxes": len(issues["degenerate_boxes"]),
"n_boxes_out_of_bounds": len(issues["boxes_out_of_bounds"]),
"n_json_size_mismatch": len(issues["json_size_mismatch"]),
"n_missing_masks": len(issues["missing_masks"]),
"n_empty_masks": len(issues["empty_masks"]),
"n_low_box_mask_iou_lt_0.5": len(issues["low_box_mask_iou"]),
"median_box_mask_iou": float(d["box_mask_iou"].median(skipna=True)),
"split_table": tab.to_dict(),
"eda": stats,
"details": {k: (v[:100] if isinstance(v, list) else v) for k, v in issues.items()}}
save_json(audit, METRICS_DIR / f"B_data_audit_{SEED_TAG}.json")
sel = qualitative(d, n=args.n_examples)
sel.to_csv(METRICS_DIR / f"B_seed_selected_examples_{SEED_TAG}.csv", index=False)
print("\n=== ANNOTATION AUDIT ===")
for k, v in audit.items():
if k.startswith("n_") or k.startswith("median_"):
print(f" {k:34s}: {v}")
print(f"\n box area fraction: mean={stats['box_area_fraction']['mean']:.4f} "
f"median={stats['box_area_fraction']['median']:.4f}")
print(f" size buckets : {stats['size_buckets']}")
print("\n" + stats["class_imbalance_note"])
if __name__ == "__main__":
main()