| """Build the normalized polygon dataset used by SAMPoly-style training. |
| |
| The importer is intentionally strict: bbox-only annotations are rejected because |
| they cannot supervise true polygon boundaries or vertices. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import random |
| import shutil |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
| from typing import Any |
|
|
| from PIL import Image |
|
|
|
|
| IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff"} |
| MASK_SUFFIXES = {".png", ".tif", ".tiff", ".jpg", ".jpeg"} |
| POLYGON_FORMATS = {"coco_polygon", "coco_segmentation", "geojson", "shp", "mask", "binary_mask", "semantic_mask"} |
| BBOX_FORMATS = {"bbox", "box_txt", "coco_bbox", "voc_bbox"} |
|
|
|
|
| @dataclass |
| class ImportStats: |
| scanned: int = 0 |
| accepted: int = 0 |
| rejected: int = 0 |
| accepted_masks: int = 0 |
| accepted_polygons: int = 0 |
| rejected_bbox_only: int = 0 |
| rejected_missing_image: int = 0 |
| rejected_missing_label: int = 0 |
| rejected_unknown_format: int = 0 |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--manifest", required=True, help="JSONL manifest with standardized sample records.") |
| parser.add_argument("--bbox-source-root", default=None, help="Optional local bbox dataset mirror for rejection auditing.") |
| parser.add_argument("--extra-source-root", action="append", default=[], help="Local source roots to scan for mask/polygon datasets.") |
| parser.add_argument("--output-root", required=True) |
| parser.add_argument("--train-ratio", type=float, default=0.8) |
| parser.add_argument("--val-ratio", type=float, default=0.1) |
| parser.add_argument("--seed", type=int, default=0) |
| parser.add_argument("--min-quality-score", type=float, default=0.9) |
| parser.add_argument("--element", default=None) |
| return parser.parse_args() |
|
|
|
|
| def read_jsonl(path: Path) -> list[dict[str, Any]]: |
| rows = [] |
| if not path.exists(): |
| return rows |
| for line in path.read_text(encoding="utf-8").splitlines(): |
| if line.strip(): |
| rows.append(json.loads(line)) |
| return rows |
|
|
|
|
| def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text("\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + ("\n" if rows else ""), encoding="utf-8") |
|
|
|
|
| def safe_name(sample_id: str, fallback: str) -> str: |
| raw = sample_id or Path(fallback).stem |
| return "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in raw) |
|
|
|
|
| def local_path_from_record(record: dict[str, Any], key: str) -> Path | None: |
| value = record.get(key) |
| if not value or not isinstance(value, str): |
| return None |
| if value.startswith("hf://"): |
| return None |
| path = Path(value) |
| return path if path.exists() else None |
|
|
|
|
| def find_local_bbox_image(record: dict[str, Any], bbox_root: Path | None) -> Path | None: |
| if bbox_root is None: |
| return None |
| source = str(record.get("image_path") or "") |
| stem = Path(source).stem.lower() |
| for split in ("train", "val", "test"): |
| image_dir = bbox_root / "images" / split |
| if not image_dir.exists(): |
| continue |
| for path in image_dir.iterdir(): |
| if path.suffix.lower() in IMAGE_SUFFIXES and path.stem.lower().endswith(stem): |
| return path |
| return None |
|
|
|
|
| def mask_has_foreground(path: Path) -> bool: |
| try: |
| img = Image.open(path).convert("L") |
| extrema = img.getextrema() |
| return bool(extrema and extrema[1] > 0) |
| except Exception: |
| return False |
|
|
|
|
| def find_extra_samples(root: Path, min_quality: float, element: str | None) -> list[dict[str, Any]]: |
| rows: list[dict[str, Any]] = [] |
| for image_dir in root.rglob("images"): |
| if not image_dir.is_dir(): |
| continue |
| split = image_dir.parent.name if image_dir.parent.name in {"train", "val", "test"} else None |
| mask_dir_candidates = [ |
| image_dir.parent / "masks", |
| image_dir.parent.parent / "masks" / image_dir.name, |
| image_dir.parent.parent / "masks" / (split or ""), |
| ] |
| for image_path in image_dir.iterdir(): |
| if image_path.suffix.lower() not in IMAGE_SUFFIXES: |
| continue |
| mask_path = None |
| for mask_dir in mask_dir_candidates: |
| if not mask_dir.exists(): |
| continue |
| for suffix in MASK_SUFFIXES: |
| candidate = mask_dir / f"{image_path.stem}{suffix}" |
| if candidate.exists(): |
| mask_path = candidate |
| break |
| if mask_path: |
| break |
| if not mask_path or not mask_has_foreground(mask_path): |
| continue |
| rows.append( |
| { |
| "sample_id": f"local_{safe_name(image_path.stem, image_path.name)}", |
| "element": element or "unknown", |
| "task_type": "polygon_extraction", |
| "image_path": str(image_path), |
| "mask_path": str(mask_path), |
| "annotation_path": str(mask_path), |
| "annotation_format": "binary_mask", |
| "quality_score": max(min_quality, 0.95), |
| "quality_flags": ["accepted", "local_mask_pair", "polygon_trainable"], |
| "split": split, |
| } |
| ) |
| return rows |
|
|
|
|
| def split_rows(rows: list[dict[str, Any]], train_ratio: float, val_ratio: float, seed: int) -> dict[str, list[dict[str, Any]]]: |
| grouped = {"train": [], "val": [], "test": []} |
| presplit = [row for row in rows if row.get("split") in grouped] |
| unsplit = [row for row in rows if row.get("split") not in grouped] |
| for row in presplit: |
| grouped[str(row["split"])].append(row) |
| random.Random(seed).shuffle(unsplit) |
| n = len(unsplit) |
| n_train = int(n * train_ratio) |
| n_val = int(n * val_ratio) |
| grouped["train"].extend(unsplit[:n_train]) |
| grouped["val"].extend(unsplit[n_train : n_train + n_val]) |
| grouped["test"].extend(unsplit[n_train + n_val :]) |
| return grouped |
|
|
|
|
| def copy_sample(row: dict[str, Any], split: str, output_root: Path) -> dict[str, Any]: |
| image_path = Path(str(row["image_path"])) |
| mask_path = Path(str(row.get("mask_path") or row.get("annotation_path"))) |
| name = safe_name(str(row.get("sample_id") or image_path.stem), image_path.name) |
| image_out = output_root / "images" / split / f"{name}{image_path.suffix.lower()}" |
| mask_out = output_root / "masks" / split / f"{name}.png" |
| image_out.parent.mkdir(parents=True, exist_ok=True) |
| mask_out.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copy2(image_path, image_out) |
| Image.open(mask_path).convert("L").save(mask_out) |
| copied = dict(row) |
| copied.update( |
| { |
| "sample_id": name, |
| "split": split, |
| "image_path": str(image_out), |
| "mask_path": str(mask_out), |
| "annotation_path": str(mask_out), |
| "annotation_format": "binary_mask", |
| "task_type": "polygon_extraction", |
| "quality_flags": sorted(set(row.get("quality_flags", []) + ["accepted_for_polygon_training"])), |
| } |
| ) |
| return copied |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| manifest = Path(args.manifest) |
| output_root = Path(args.output_root) |
| output_root.mkdir(parents=True, exist_ok=True) |
| bbox_root = Path(args.bbox_source_root) if args.bbox_source_root else None |
| stats = ImportStats() |
| accepted: list[dict[str, Any]] = [] |
| rejected: list[dict[str, Any]] = [] |
|
|
| records = read_jsonl(manifest) |
| for root in args.extra_source_root: |
| records.extend(find_extra_samples(Path(root), args.min_quality_score, args.element)) |
|
|
| for record in records: |
| stats.scanned += 1 |
| if args.element and record.get("element") != args.element: |
| continue |
| quality = float(record.get("quality_score") or 0.0) |
| fmt = str(record.get("annotation_format") or "").lower() |
| image_path = local_path_from_record(record, "image_path") or find_local_bbox_image(record, bbox_root) |
| label_path = local_path_from_record(record, "mask_path") or local_path_from_record(record, "annotation_path") |
|
|
| reject_reason = None |
| if quality < args.min_quality_score: |
| reject_reason = "quality_below_threshold" |
| elif fmt in BBOX_FORMATS: |
| reject_reason = "bbox_only_not_polygon_trainable" |
| stats.rejected_bbox_only += 1 |
| elif fmt not in POLYGON_FORMATS: |
| reject_reason = "unknown_or_unsupported_annotation_format" |
| stats.rejected_unknown_format += 1 |
| elif image_path is None: |
| reject_reason = "missing_local_image" |
| stats.rejected_missing_image += 1 |
| elif label_path is None or not label_path.exists(): |
| reject_reason = "missing_local_mask_or_polygon" |
| stats.rejected_missing_label += 1 |
| elif fmt in {"mask", "binary_mask", "semantic_mask"} and not mask_has_foreground(label_path): |
| reject_reason = "empty_or_invalid_mask" |
|
|
| if reject_reason: |
| item = dict(record) |
| item["polygon_import_status"] = "rejected" |
| item["reject_reason"] = reject_reason |
| if image_path: |
| item["local_image_path"] = str(image_path) |
| rejected.append(item) |
| stats.rejected += 1 |
| continue |
|
|
| item = dict(record) |
| item["image_path"] = str(image_path) |
| item["mask_path"] = str(label_path) |
| item["annotation_path"] = str(label_path) |
| item["polygon_import_status"] = "accepted" |
| accepted.append(item) |
| stats.accepted += 1 |
| if fmt in {"mask", "binary_mask", "semantic_mask"}: |
| stats.accepted_masks += 1 |
| else: |
| stats.accepted_polygons += 1 |
|
|
| grouped = split_rows(accepted, args.train_ratio, args.val_ratio, args.seed) |
| copied_rows = [] |
| for split, rows in grouped.items(): |
| for row in rows: |
| copied_rows.append(copy_sample(row, split, output_root)) |
|
|
| write_jsonl(output_root / "manifests" / "accepted_polygon_samples.jsonl", copied_rows) |
| write_jsonl(output_root / "manifests" / "rejected_polygon_samples.jsonl", rejected) |
| summary = { |
| **asdict(stats), |
| "output_root": str(output_root), |
| "splits": {split: len(rows) for split, rows in grouped.items()}, |
| "quality_policy": "Only mask or polygon annotations are accepted for SAMPoly-style polygon training; bbox-only samples are rejected.", |
| "source_manifest": str(manifest), |
| } |
| (output_root / "dataset_card.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8") |
| print(json.dumps(summary, indent=2, ensure_ascii=False), flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|