Detector / scripts /detector_lib.py
Benxelua's picture
Add reproducible six-run detector-domain training package
4409fdb verified
Raw History Blame Contribute Delete
9.28 kB
"""Shared locked-protocol dataset, model, and COCO evaluator utilities."""
from __future__ import annotations
import contextlib
import io
import json
import xml.etree.ElementTree as ET
from pathlib import Path
import torch
from PIL import Image
from torch.utils.data import Dataset
CLASS_NAMES = ("car", "motorbike", "bicycle", "chair", "diningtable", "bottle", "tvmonitor", "bus")
EXPECTED = {"train": 1647, "val": 183, "test": 400}
def resolve_image(dataset_root: Path, domain: str, basename: str) -> Path:
folder = "RGB_normal" if domain == "bright" else "RGB_Dark"
hits = list(dataset_root.rglob(f"{folder}/{basename}"))
if len(hits) != 1:
hits = [p for p in dataset_root.rglob(basename) if folder.lower() in str(p).lower()]
if len(hits) != 1:
raise RuntimeError(f"expected exactly one {domain} image for {basename}, found {hits}")
return hits[0]
def read_yolo(path: Path, width: int, height: int):
boxes, labels = [], []
for line in path.read_text().splitlines():
if not line.strip():
continue
cls, cx, cy, bw, bh = map(float, line.split())
if not 0 <= int(cls) < len(CLASS_NAMES):
raise RuntimeError(f"invalid class id {cls} in {path}")
boxes.append([(cx - bw / 2) * width, (cy - bh / 2) * height,
(cx + bw / 2) * width, (cy + bh / 2) * height])
labels.append(int(cls))
return (torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4),
torch.tensor(labels, dtype=torch.int64))
def read_voc(path: Path):
boxes, labels = [], []
for obj in ET.parse(path).getroot().findall("object"):
name = obj.findtext("name")
if name not in CLASS_NAMES:
raise RuntimeError(f"unknown VOC class {name!r} in {path}")
box = obj.find("bndbox")
boxes.append([float(box.findtext("xmin")), float(box.findtext("ymin")),
float(box.findtext("xmax")), float(box.findtext("ymax"))])
labels.append(CLASS_NAMES.index(name))
return (torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4),
torch.tensor(labels, dtype=torch.int64))
class LockedLODDataset(Dataset):
def __init__(self, manifest: Path, dataset_root: Path, labels_root: Path, limit: int | None = None):
payload = json.loads(Path(manifest).read_text())
self.domain = payload["domain"]
self.split = payload["split"]
self.items = payload["items"][:limit]
if len(payload["items"]) != EXPECTED[self.split]:
raise RuntimeError(f"{manifest}: expected {EXPECTED[self.split]} locked rows, got {len(payload['items'])}")
self.dataset_root, self.labels_root = Path(dataset_root), Path(labels_root)
def __len__(self):
return len(self.items)
def __getitem__(self, index):
item = self.items[index]
image = Image.open(resolve_image(self.dataset_root, self.domain, item["image_basename"])).convert("RGB")
# The public LOD Kaggle dataset contains the source VOC annotations.
# Prefer them, retaining copied YOLO labels only as a local fallback.
group = "RGB-normal-Annotations" if self.domain == "bright" else "RGB-dark-Annotations"
voc = self.dataset_root / group / group / (Path(item["image_basename"]).stem + ".xml")
if voc.is_file():
boxes, labels = read_voc(voc)
else:
label_domain = "normal" if self.domain == "bright" else "dark"
label = self.labels_root / label_domain / self.split / "labels" / item["label_basename"]
if not label.is_file():
raise FileNotFoundError(f"missing VOC and fallback label for {item['pair_id']}")
boxes, labels = read_yolo(label, *image.size)
return image, {"boxes": boxes, "labels": labels,
"image_id": torch.tensor(index),
"orig_size": torch.tensor([image.height, image.width])}
def collate(batch):
return tuple(zip(*batch))
def build_model(cfg):
detector = cfg["detector"]
if detector == "rtdetr":
from transformers import RTDetrV2ForObjectDetection, RTDetrImageProcessor
processor = RTDetrImageProcessor.from_pretrained(cfg["pretrained"])
model = RTDetrV2ForObjectDetection.from_pretrained(
cfg["pretrained"], num_labels=len(CLASS_NAMES),
id2label=dict(enumerate(CLASS_NAMES)), label2id={n: i for i, n in enumerate(CLASS_NAMES)},
ignore_mismatched_sizes=True,
)
return model, processor, "hf"
if detector == "fasterrcnn_r50_fpn":
from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2, FasterRCNN_ResNet50_FPN_V2_Weights
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
model = fasterrcnn_resnet50_fpn_v2(weights=FasterRCNN_ResNet50_FPN_V2_Weights.COCO_V1)
model.roi_heads.box_predictor = FastRCNNPredictor(model.roi_heads.box_predictor.cls_score.in_features, len(CLASS_NAMES) + 1)
return model, None, "torchvision"
if detector == "ssdlite320_mobilenet_v3_large":
from torchvision.models.detection import ssdlite320_mobilenet_v3_large, SSDLite320_MobileNet_V3_Large_Weights
from torchvision.models.detection.ssdlite import SSDLiteClassificationHead
model = ssdlite320_mobilenet_v3_large(weights=SSDLite320_MobileNet_V3_Large_Weights.COCO_V1)
# TorchVision exposes each SSDLite predictor as a Sequential block;
# its depthwise Conv2d is nested under block[0][0]. Derive the
# feature widths from the loaded COCO head rather than hard-coding a
# version-specific list.
in_channels = [module[0][0].in_channels for module in model.head.classification_head.module_list]
num_anchors = model.anchor_generator.num_anchors_per_location()
model.head.classification_head = SSDLiteClassificationHead(
in_channels, num_anchors, len(CLASS_NAMES) + 1, torch.nn.BatchNorm2d
)
return model, None, "torchvision"
raise ValueError(f"unsupported detector: {detector}")
def image_tensors(images, device):
from torchvision.transforms.functional import pil_to_tensor
return [pil_to_tensor(image).float().div(255).to(device) for image in images]
def hf_batch(processor, images, targets, device):
annotations = []
for index, target in enumerate(targets):
boxes = target["boxes"]
xywh = torch.stack((boxes[:, 0], boxes[:, 1], boxes[:, 2] - boxes[:, 0], boxes[:, 3] - boxes[:, 1]), 1)
annotations.append({"image_id": index, "annotations": [
{"bbox": box.tolist(), "category_id": int(label), "area": float(box[2] * box[3]), "iscrowd": 0}
for box, label in zip(xywh, target["labels"])
]})
encoded = processor(images=list(images), annotations=annotations, return_tensors="pt")
return {key: (value.to(device) if hasattr(value, "to") else [{k: v.to(device) for k, v in x.items()} for x in value])
for key, value in encoded.items()}
class CanonicalMAP:
def __init__(self):
self.images, self.annotations, self.predictions, self.annotation_id = {}, [], [], 1
@staticmethod
def _xywh(box):
x1, y1, x2, y2 = map(float, box.tolist())
return [x1, y1, max(0.0, x2 - x1), max(0.0, y2 - y1)]
def update(self, predictions, targets):
for pred, target in zip(predictions, targets):
image_id = int(target["image_id"])
h, w = map(int, target["orig_size"].tolist())
self.images[image_id] = {"id": image_id, "height": h, "width": w}
for box, label in zip(target["boxes"], target["labels"]):
bbox = self._xywh(box)
self.annotations.append({"id": self.annotation_id, "image_id": image_id, "category_id": int(label) + 1,
"bbox": bbox, "area": bbox[2] * bbox[3], "iscrowd": 0})
self.annotation_id += 1
for box, score, label in zip(pred["boxes"], pred["scores"], pred["labels"]):
self.predictions.append({"image_id": image_id, "category_id": int(label) + 1,
"bbox": self._xywh(box), "score": float(score)})
def compute(self):
from pycocotools.coco import COCO
from pycocotools.cocoeval import COCOeval
gt = COCO(); gt.dataset = {"images": list(self.images.values()), "annotations": self.annotations,
"categories": [{"id": i + 1, "name": n} for i, n in enumerate(CLASS_NAMES)]}; gt.createIndex()
dt = gt.loadRes(self.predictions) if self.predictions else COCO()
if not self.predictions:
dt.dataset = {"images": list(self.images.values()), "annotations": [], "categories": gt.dataset["categories"]}; dt.createIndex()
evaluator = COCOeval(gt, dt, "bbox"); evaluator.params.imgIds = sorted(self.images); evaluator.params.catIds = list(range(1, 9)); evaluator.params.maxDets = [1, 10, 100]
with contextlib.redirect_stdout(io.StringIO()):
evaluator.evaluate(); evaluator.accumulate(); evaluator.summarize()
return {"map50_95": float(evaluator.stats[0]), "map50": float(evaluator.stats[1])}