cuibinge's picture
Align code with polygon-only training
02e2747 verified
Raw
History Blame Contribute Delete
11 kB
"""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()