RAP_DocLayout_DetectionD / scripts /prepare_dataset.py
RISHIVEL's picture
space deploy: fix zerogpu pickling
087643a
Raw
History Blame Contribute Delete
12.9 kB
# Converts the HF DocLayNet-base dataset into the image/label layout
# Ultralytics expects. Everything that can go wrong here goes wrong
# silently - bad labels don't raise, training just quietly learns the
# wrong thing. Three things beyond the obvious conversion:
# 1. collapses repeated per-line boxes (a 40-region page can show up as
# ~1,100 duplicate annotations otherwise)
# 2. drops degenerate boxes and counts them (zero-area -> NaN loss later)
# 3. can render a sample with decoded labels drawn on, so I can eyeball
# that the class index mapping is actually right
#
# Usage:
# python scripts/prepare_dataset.py --out data/doclaynet
# python scripts/prepare_dataset.py --out data/doclaynet --verify 12
# python scripts/prepare_dataset.py --out data/doclaynet --limit 20 # smoke test
from __future__ import annotations
import argparse
import json
import random
from collections import Counter
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.constants import CLASS_NAMES, ID_TO_CLASS # noqa: E402
HF_DATASET = "pierreguillou/DocLayNet-base"
# HF split names don't match Ultralytics' directory convention
SPLITS = {"train": "train", "validation": "validation", "test": "test"}
def coco_to_yolo(
bbox: list[float], img_w: int, img_h: int
) -> tuple[float, float, float, float]:
"""DocLayNet gives [x, y, w, h] top-left in pixels. Ultralytics wants
[x_centre, y_centre, w, h] normalised 0-1. Easy to get the corner-to-
centre shift wrong without noticing - tested for that reason."""
x, y, w, h = bbox
x_centre = (x + w / 2) / img_w
y_centre = (y + h / 2) / img_h
return (x_centre, y_centre, w / img_w, h / img_h)
def is_valid_bbox(bbox: list[float], img_w: int, img_h: int) -> bool:
"""Rejects zero-area/out-of-bounds boxes before they turn into NaN
losses several epochs in. Counts rejections rather than just dropping
silently."""
x, y, w, h = bbox
if w <= 0 or h <= 0:
return False
if x < 0 or y < 0:
return False
if x + w > img_w or y + h > img_h:
return False
return True
def dedupe_annotations(
bboxes: list[list[float]], categories: list[int]
) -> list[tuple[tuple[float, ...], int]]:
"""DocLayNet-base repeats a block's box once per text line inside it -
a paragraph with 6 lines shows up as 6 identical entries. Dedupe on
(box, category) rather than box alone, since two classes occasionally
share the same extent. First-seen order kept so output is reproducible."""
if len(bboxes) != len(categories):
raise ValueError(
f"bboxes and categories must be the same length, "
f"got {len(bboxes)} and {len(categories)}. "
"If this fires, the dataset schema is not what this script assumes."
)
seen: set[tuple[tuple[float, ...], int]] = set()
unique: list[tuple[tuple[float, ...], int]] = []
for bbox, category in zip(bboxes, categories):
key = (tuple(float(v) for v in bbox), int(category))
if key not in seen:
seen.add(key)
unique.append(key)
return unique
def _write_split(
dataset, split_name: str, out_dir: Path, limit: int | None
) -> dict:
"""Writes one split to disk, returns the counts (these end up in the
memo, especially the dedupe ratio - "removed 96% of raw annotations"
needs a number next to it or it sounds like something broke)."""
image_dir = out_dir / "images" / split_name
label_dir = out_dir / "labels" / split_name
image_dir.mkdir(parents=True, exist_ok=True)
label_dir.mkdir(parents=True, exist_ok=True)
rows = dataset[split_name]
total = len(rows) if limit is None else min(limit, len(rows))
stats = {
"images": 0,
"raw_annotations": 0,
"after_dedupe": 0,
"dropped_invalid": 0,
"empty_pages": 0,
"class_counts": Counter(),
"doc_category_counts": Counter(),
}
# per-image manifest for evaluate.py's per-category mAP breakdown, and
# for measuring train/test source-PDF leakage
manifest: list[dict] = []
for index in range(total):
row = rows[index]
image = row["image"]
img_w, img_h = image.size
stats["raw_annotations"] += len(row["bboxes_block"])
annotations = dedupe_annotations(row["bboxes_block"], row["categories"])
stats["after_dedupe"] += len(annotations)
lines = []
for bbox, category in annotations:
if not is_valid_bbox(list(bbox), img_w, img_h):
stats["dropped_invalid"] += 1
continue
xc, yc, w, h = coco_to_yolo(list(bbox), img_w, img_h)
lines.append(f"{category} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}")
stats["class_counts"][ID_TO_CLASS.get(category, f"UNKNOWN_{category}")] += 1
# blank page is legit, but also what a conversion bug looks like
if not lines:
stats["empty_pages"] += 1
stem = f"{split_name}_{index:06d}"
image.convert("RGB").save(image_dir / f"{stem}.png")
(label_dir / f"{stem}.txt").write_text("\n".join(lines), encoding="utf-8")
doc_category = row.get("doc_category", "unknown")
stats["doc_category_counts"][doc_category] += 1
stats["images"] += 1
manifest.append({
"stem": stem,
"doc_category": doc_category,
"source_pdf": row.get("original_filename", "unknown"),
"num_regions": len(lines),
})
if stats["images"] % 500 == 0:
print(f" {split_name}: {stats['images']}/{total}", flush=True)
(out_dir / f"manifest_{split_name}.json").write_text(
json.dumps(manifest, indent=2), encoding="utf-8"
)
stats["class_counts"] = dict(stats["class_counts"])
stats["doc_category_counts"] = dict(stats["doc_category_counts"])
return stats
def _render_label_check(dataset, out_dir: Path, sample_size: int) -> None:
"""Draws decoded class labels on a sample of pages so I can eyeball
them. My class ordering (0-indexed alphabetical) is inferred, not
confirmed - if it's off by one, Table becomes Section-header
everywhere and training won't complain about it. No automated test
catches that, only looking does."""
from PIL import ImageDraw
from scripts._render_utils import load_label_font
label_font = load_label_font(28)
check_dir = out_dir / "label_check"
check_dir.mkdir(parents=True, exist_ok=True)
rows = dataset["train"]
random.seed(42)
indices = random.sample(range(len(rows)), min(sample_size, len(rows)))
palette = [
"#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
"#46f0f0", "#f032e6", "#bcf60c", "#fabebe", "#008080",
]
for index in indices:
row = rows[index]
image = row["image"].convert("RGB")
draw = ImageDraw.Draw(image)
for bbox, category in dedupe_annotations(row["bboxes_block"], row["categories"]):
x, y, w, h = bbox
colour = palette[category % len(palette)]
label = ID_TO_CLASS.get(category, "?")
draw.rectangle([x, y, x + w, y + h], outline=colour, width=4)
# solid background behind the label - plain text is invisible
# on dark scanned content otherwise
text_box = draw.textbbox((x, y), label, font=label_font)
draw.rectangle(
[text_box[0] - 2, text_box[1] - 2, text_box[2] + 2, text_box[3] + 2],
fill=colour,
)
draw.text((x, y), label, font=label_font, fill="white")
image.save(check_dir / f"check_{index:06d}.png")
print(f"\nWrote {len(indices)} annotated pages to {check_dir}")
print("LOOK AT THESE before training. If the box labelled 'Table' is not")
print("drawn around a table, the class index base is wrong and everything")
print("downstream will be quietly meaningless.\n")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", default="data/doclaynet", help="output root")
parser.add_argument(
"--limit", type=int, default=None,
help="cap images per split - for smoke tests only, not for a real run",
)
parser.add_argument(
"--verify", type=int, default=0,
help="render this many annotated pages for visual label checking",
)
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
from datasets import ClassLabel, Features, Sequence, Value
from datasets import load_dataset
from datasets.features import Image as HFImage
# The dataset's own loading script declares these as int64, but the
# real coordinates are floats (hit one directly: 139.664355). Newer
# pyarrow refuses that lossy cast where older versions silently
# floored it. Can't edit someone else's script, so override the
# schema on load instead - copied field-for-field, only these two
# fixed to float64.
doclaynet_features = Features({
"id": Value("string"),
"texts": Sequence(Value("string")),
"bboxes_block": Sequence(Sequence(Value("float64"))),
"bboxes_line": Sequence(Sequence(Value("float64"))),
"categories": Sequence(ClassLabel(names=CLASS_NAMES)),
"image": HFImage(),
"page_hash": Value("string"),
"original_filename": Value("string"),
"page_no": Value("int32"),
"num_pages": Value("int32"),
"original_width": Value("int32"),
"original_height": Value("int32"),
"coco_width": Value("int32"),
"coco_height": Value("int32"),
"collection": Value("string"),
"doc_category": Value("string"),
})
print(f"Loading {HF_DATASET} (3.8 GB on first run, cached after)...", flush=True)
try:
# trust_remote_code=True: this repo ships a loading script rather
# than static parquet. Without this it prompts for confirmation,
# which hangs forever under Kaggle's non-interactive commit runs.
# Checked what the script does first - it's just the author's own
# DocLayNet -> HF datasets conversion, nothing else.
dataset = load_dataset(HF_DATASET, trust_remote_code=True, features=doclaynet_features)
except RuntimeError as error:
# datasets>=4.0.0 dropped loading-script support entirely -
# requirements.txt pins <4.0.0 for this reason, but if an
# environment already has a newer version cached, this is what
# they'll hit.
if "no longer supported" in str(error):
raise RuntimeError(
f"{error}\n\n"
"This dataset repo uses the old Hugging Face 'loading script' "
"format, which datasets>=4.0.0 removed support for entirely. "
"Fix: pip install \"datasets<4.0.0\" (already pinned in "
"requirements.txt - your environment likely has a newer "
"version cached from something else)."
) from error
raise
if args.verify:
_render_label_check(dataset, out_dir, args.verify)
report = {"dataset": HF_DATASET, "splits": {}}
for hf_split, dir_name in SPLITS.items():
print(f"Converting split '{hf_split}'...", flush=True)
report["splits"][dir_name] = _write_split(dataset, hf_split, out_dir, args.limit)
_write_data_yaml(out_dir)
report_path = out_dir / "prep_report.json"
report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"\nWrote {report_path}")
for split, stats in report["splits"].items():
raw, kept = stats["raw_annotations"], stats["after_dedupe"]
shrink = (1 - kept / raw) * 100 if raw else 0
print(
f" {split:11s} {stats['images']:5d} images | "
f"{raw:7d} raw -> {kept:6d} annotations ({shrink:.1f}% were repeats) | "
f"{stats['dropped_invalid']} invalid dropped"
)
def _write_data_yaml(out_dir: Path) -> None:
"""Generates the Ultralytics config instead of hand-writing it, so
class names can't drift from app/constants.py."""
names = "\n".join(f" {idx}: {name}" for idx, name in enumerate(CLASS_NAMES))
yaml = (
"# Generated by scripts/prepare_dataset.py - do not edit by hand.\n"
"# Class names come from app/constants.py so the two cannot drift.\n"
f"path: {out_dir.resolve().as_posix()}\n"
"train: images/train\n"
"val: images/validation\n"
"test: images/test\n"
"\n"
"names:\n"
f"{names}\n"
)
(out_dir / "doclaynet.yaml").write_text(yaml, encoding="utf-8")
if __name__ == "__main__":
main()