Skip to content

formats

Readers and writers for the five annotation formats, behind hbb2obb-convert and hbb2obb-view. Every format reads into, and writes from, one representation: a FrameAnnotations per frame holding a Box per object, with four corners in absolute pixels. A horizontal box is a Box whose corners are axis-aligned. The guide has a worked example.

from pathlib import Path

from hbb2obb import formats

frames, names, fmt = formats.read_set(Path("data/labels_obb_gt"), sizes=formats.image_sizes(Path("data/images")))
box = frames[0].boxes[0]
print(box.cls, box.quad, box.xyxy, box.is_axis_aligned)

Representation

Box dataclass

A single annotation: four corners in absolute pixel coordinates.

Source code in hbb2obb/formats.py
@dataclass
class Box:
    """A single annotation: four corners in absolute pixel coordinates."""

    cls: int
    quad: np.ndarray  # (4, 2) float
    score: Optional[float] = None
    difficult: int = 0

    def __post_init__(self):
        self.quad = np.asarray(self.quad, dtype=float).reshape(4, 2)

    @property
    def xyxy(self) -> Tuple[float, float, float, float]:
        """The axis-aligned envelope of the quad."""
        return (
            float(self.quad[:, 0].min()),
            float(self.quad[:, 1].min()),
            float(self.quad[:, 0].max()),
            float(self.quad[:, 1].max()),
        )

    @property
    def is_axis_aligned(self) -> bool:
        """True when the quad is a rectangle with sides parallel to the image axes."""
        return len(np.unique(np.round(self.quad[:, 0], 6))) <= 2 and len(np.unique(np.round(self.quad[:, 1], 6))) <= 2

    @property
    def area(self) -> float:
        """Shoelace area of the quad."""
        x, y = self.quad[:, 0], self.quad[:, 1]
        return float(abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) / 2)

xyxy property

xyxy: Tuple[float, float, float, float]

The axis-aligned envelope of the quad.

is_axis_aligned property

is_axis_aligned: bool

True when the quad is a rectangle with sides parallel to the image axes.

area property

area: float

Shoelace area of the quad.

FrameAnnotations dataclass

Every box on one image.

Source code in hbb2obb/formats.py
@dataclass
class FrameAnnotations:
    """Every box on one image."""

    stem: str
    width: int
    height: int
    boxes: List[Box] = field(default_factory=list)

    def __len__(self) -> int:
        return len(self.boxes)

canonicalize

canonicalize(frames: Iterable[FrameAnnotations], precision: int = DEFAULT_PRECISION) -> List[FrameAnnotations]

Round every quad once, so that all output formats derive from one source.

This is what keeps the integer formats consistent with each other and with the envelope of the box they came from. Rounding is monotonic, so min(round(x)) == round(min(x)) holds only when both sides round the same values; deriving one format from full precision and another from a rounded canonical is what makes them disagree on boxes that land exactly on a half pixel.

Source code in hbb2obb/formats.py
def canonicalize(frames: Iterable[FrameAnnotations], precision: int = DEFAULT_PRECISION) -> List[FrameAnnotations]:
    """
    Round every quad once, so that all output formats derive from one source.

    This is what keeps the integer formats consistent with each other and with the envelope of the
    box they came from. Rounding is monotonic, so ``min(round(x)) == round(min(x))`` holds only when
    both sides round the same values; deriving one format from full precision and another from a
    rounded canonical is what makes them disagree on boxes that land exactly on a half pixel.
    """
    out = []
    for fr in frames:
        boxes = [Box(b.cls, np.round(b.quad, precision), b.score, b.difficult) for b in fr.boxes]
        out.append(FrameAnnotations(fr.stem, fr.width, fr.height, boxes))
    return out

Whole sets

read_set

read_set(source: Path, fmt: Optional[str] = None, names: Optional[Sequence[str]] = None, sizes: Optional[Dict[str, Tuple[int, int]]] = None, default_size: Optional[Tuple[int, int]] = None) -> Tuple[List[FrameAnnotations], List[str], str]

Read a whole annotation set. Returns its frames, the class names, and the detected format.

source is a directory of per-frame files, or the .json file itself for COCO. Image sizes come from sizes (keyed by frame stem), from default_size, or from the format itself where it records them; they are needed to denormalize relative YOLO coordinates and to write the formats that declare an image size.

Source code in hbb2obb/formats.py
def read_set(
    source: Path,
    fmt: Optional[str] = None,
    names: Optional[Sequence[str]] = None,
    sizes: Optional[Dict[str, Tuple[int, int]]] = None,
    default_size: Optional[Tuple[int, int]] = None,
) -> Tuple[List[FrameAnnotations], List[str], str]:
    """
    Read a whole annotation set. Returns its frames, the class names, and the detected format.

    ``source`` is a directory of per-frame files, or the .json file itself for COCO. Image sizes
    come from ``sizes`` (keyed by frame stem), from ``default_size``, or from the format itself
    where it records them; they are needed to denormalize relative YOLO coordinates and to write
    the formats that declare an image size.
    """
    fmt = fmt or detect_format(source)
    sizes = sizes or {}
    known = list(names) if names is not None else None
    discovered: List[str] = []

    def size_for(stem: str) -> Tuple[int, int]:
        if stem in sizes:
            return sizes[stem]
        if default_size is not None:
            return default_size
        return (0, 0)

    if fmt == "coco":
        path = source if source.is_file() else next(iter(sorted(source.glob("*.json"))))
        frames, found = read_coco(path, known)
        for frame in frames:
            if not frame.width or not frame.height:
                frame.width, frame.height = size_for(frame.stem)
        return frames, found, fmt

    if not source.is_dir():
        raise ValueError(f"{fmt} annotations are one file per frame, so {source} must be a directory")

    frames: List[FrameAnnotations] = []
    for path in label_files(source, fmt):
        stem = path.stem
        width, height = size_for(stem)
        if fmt == "yolo":
            boxes = read_yolo(path, width, height)
        elif fmt == "dota":
            if known is None:
                raise ValueError("DOTA files name their classes, so a label map is required to read them")
            boxes = read_dota(path, known)
        elif fmt == "voc":
            if known is None:
                known = _names_from_voc(source)
            boxes, vw, vh = read_voc(path, known)
            width, height = (vw or width), (vh or height)
        elif fmt == "labelme":
            boxes, lw, lh, discovered = read_labelme(path, names, discovered)
            known = known or discovered
            width, height = (lw or width), (lh or height)
        else:
            raise ValueError(f"Unsupported format: {fmt}")
        frames.append(FrameAnnotations(stem, width, height, boxes))

    return frames, (known or discovered), fmt

write_set

write_set(frames: Sequence[FrameAnnotations], dest: Path, fmt: str, names: Sequence[str], kind: str, normalize: bool = False, precision: Optional[int] = None, dota_ext: str = DOTA_EXT, image_ext: str = '.jpg', coco_name: Optional[str] = None, identity: Optional[CocoIdentity] = None, **kwargs) -> List[Path]

Write a whole annotation set in one format. Returns the paths written.

Source code in hbb2obb/formats.py
def write_set(
    frames: Sequence[FrameAnnotations],
    dest: Path,
    fmt: str,
    names: Sequence[str],
    kind: str,
    normalize: bool = False,
    precision: Optional[int] = None,
    dota_ext: str = DOTA_EXT,
    image_ext: str = ".jpg",
    coco_name: Optional[str] = None,
    identity: Optional[CocoIdentity] = None,
    **kwargs,
) -> List[Path]:
    """Write a whole annotation set in one format. Returns the paths written."""
    obb = kind == "obb"
    if obb and fmt not in OBB_FORMATS:
        raise ValueError(f"{fmt} cannot represent oriented boxes; it supports {list(HBB_FORMATS)}")
    if not obb and fmt not in HBB_FORMATS:
        raise ValueError(f"{fmt} cannot represent horizontal boxes; it supports {list(OBB_FORMATS)}")

    if fmt == "coco":
        dest.mkdir(parents=True, exist_ok=True)
        path = dest / (coco_name or f"coco_annotations_{kind}.json")
        write_coco(path, frames, names, obb, image_ext=image_ext, identity=identity, **kwargs)
        return [path]

    dest.mkdir(parents=True, exist_ok=True)
    written = []
    for frame in frames:
        if fmt == "yolo":
            path = dest / f"{frame.stem}.txt"
            write_yolo(path, frame.boxes, frame.width, frame.height, obb, normalize, precision)
        elif fmt == "dota":
            path = dest / f"{frame.stem}{dota_ext}"
            write_dota(path, frame.boxes, names, **kwargs)
        elif fmt == "voc":
            path = dest / f"{frame.stem}.xml"
            write_voc(path, frame.boxes, names, frame.stem, frame.width, frame.height, image_ext, **kwargs)
        elif fmt == "labelme":
            path = dest / f"{frame.stem}.json"
            write_labelme(path, frame.boxes, names, frame.stem, frame.width, frame.height, obb, image_ext)
        else:
            raise ValueError(f"Unsupported format: {fmt}")
        written.append(path)
    return written

verify

verify(sets: Dict[str, List[FrameAnnotations]], reference: str = 'yolo') -> List[str]

Check that several readings of the same annotations agree, and report every disagreement.

The reference format carries full precision and the others are one common rounding of it, so the comparison is exact equality after rounding rather than a tolerance. difficult is only compared between the formats that can express it.

Source code in hbb2obb/formats.py
def verify(sets: Dict[str, List[FrameAnnotations]], reference: str = "yolo") -> List[str]:
    """
    Check that several readings of the same annotations agree, and report every disagreement.

    The reference format carries full precision and the others are one common rounding of it, so
    the comparison is exact equality after rounding rather than a tolerance. ``difficult`` is only
    compared between the formats that can express it.
    """
    problems: List[str] = []
    if reference not in sets:
        raise ValueError(f"Reference format {reference!r} is not among {sorted(sets)}")

    ref_by_stem = {f.stem: f for f in sets[reference]}
    for fmt, frames in sets.items():
        if fmt == reference:
            continue
        by_stem = {f.stem: f for f in frames}
        if set(by_stem) != set(ref_by_stem):
            missing = sorted(set(ref_by_stem) - set(by_stem))
            extra = sorted(set(by_stem) - set(ref_by_stem))
            if missing:
                problems.append(f"{fmt}: missing frames {missing[:5]}{' ...' if len(missing) > 5 else ''}")
            if extra:
                problems.append(f"{fmt}: unexpected frames {extra[:5]}{' ...' if len(extra) > 5 else ''}")

        for stem, ref in ref_by_stem.items():
            other = by_stem.get(stem)
            if other is None:
                continue
            if len(other) != len(ref):
                problems.append(f"{stem}: {fmt} has {len(other)} boxes, {reference} has {len(ref)}")
                continue
            for i, (a, b) in enumerate(zip(ref.boxes, other.boxes)):
                if a.cls != b.cls:
                    problems.append(f"{stem}#{i}: {fmt} class {b.cls} != {reference} class {a.cls}")
                if _round_quad(a) != _round_quad(b):
                    problems.append(f"{stem}#{i}: {fmt} corners disagree with {reference}")
    return problems

detect_format

detect_format(path: Path) -> str

Guess the format of a label file or of a directory of them, raising if it cannot.

A directory is resolved in ALL_FORMATS order rather than in file order, so that YOLO wins wherever a set ships its canonical files beside derived ones: the derived formats are rounded and cannot carry a confidence, so reading them by alphabetical accident loses information.

Source code in hbb2obb/formats.py
def detect_format(path: Path) -> str:
    """
    Guess the format of a label file or of a directory of them, raising if it cannot.

    A directory is resolved in ``ALL_FORMATS`` order rather than in file order, so that YOLO wins
    wherever a set ships its canonical files beside derived ones: the derived formats are rounded
    and cannot carry a confidence, so reading them by alphabetical accident loses information.
    """
    present = formats_present(path)
    if present:
        return present[0]
    if path.is_dir():
        raise ValueError(f"No label files found in {path}")
    raise ValueError(f"Cannot tell the format of {path}")

formats_present

formats_present(path: Path) -> List[str]

Every format holding label files under a directory, in ALL_FORMATS order.

A published set usually ships the same boxes more than once: the canonical YOLO files, a DOTA copy beside them, a COCO record at the subset root. This names all of them, so that a reader can say which one it picked and offer the rest. For a single file it is the one format that file is, and for anything unreadable it is empty.

Source code in hbb2obb/formats.py
def formats_present(path: Path) -> List[str]:
    """
    Every format holding label files under a directory, in ``ALL_FORMATS`` order.

    A published set usually ships the same boxes more than once: the canonical YOLO files, a DOTA
    copy beside them, a COCO record at the subset root. This names all of them, so that a reader
    can say which one it picked and offer the rest. For a single file it is the one format that
    file is, and for anything unreadable it is empty.
    """
    if path.is_dir():
        return [fmt for fmt in ALL_FORMATS if label_files(path, fmt)]
    fmt = sniff_format(path)
    return [fmt] if fmt else []

COCO identifiers

CocoIdentity dataclass

Identifiers and record metadata lifted from an existing COCO file, to be re-used by a new one.

A converted set is usually a derivative: one box per box of some source annotation, in the same order. write_coco numbers images and annotations 1..N, which breaks the join back to that source, and it has no way to know what record the output belongs to, so it writes an empty info and licenses. Reading them off the source file is what keeps a derived release traceable, and the match is positional: box k of a frame is the same object in both files.

info is read here for a caller that wants it, but write_coco does not carry it into the output: it describes the record being written, and a derivative is not its source.

Source code in hbb2obb/formats.py
@dataclass
class CocoIdentity:
    """
    Identifiers and record metadata lifted from an existing COCO file, to be re-used by a new one.

    A converted set is usually a derivative: one box per box of some source annotation, in the same
    order. ``write_coco`` numbers images and annotations ``1..N``, which breaks the join back to
    that source, and it has no way to know what record the output belongs to, so it writes an empty
    ``info`` and ``licenses``. Reading them off the source file is what keeps a derived release
    traceable, and the match is positional: box *k* of a frame is the same object in both files.

    ``info`` is read here for a caller that wants it, but ``write_coco`` does not carry it into the
    output: it describes the record being written, and a derivative is not its source.
    """

    image_ids: Dict[str, int]
    annotation_ids: Dict[str, List[int]]
    classes: Dict[str, List[Optional[str]]]
    info: dict = field(default_factory=dict)
    licenses: list = field(default_factory=list)
    supercategory: Optional[str] = None

read_coco_identity

read_coco_identity(path: Path) -> CocoIdentity

Read a COCO file for its identifiers and record metadata, not its geometry.

Annotations are grouped by frame in the order the file lists them, which is the order write_coco writes and the order a one-box-per-box conversion preserves.

Source code in hbb2obb/formats.py
def read_coco_identity(path: Path) -> CocoIdentity:
    """
    Read a COCO file for its identifiers and record metadata, not its geometry.

    Annotations are grouped by frame in the order the file lists them, which is the order
    ``write_coco`` writes and the order a one-box-per-box conversion preserves.
    """
    data = json.loads(path.read_text(encoding="utf-8"))
    category_names = {c["id"]: c.get("name", str(c["id"])) for c in data.get("categories", [])}
    supercategories = {c.get("supercategory") for c in data.get("categories", [])} - {None}

    stems: Dict[int, str] = {}
    image_ids: Dict[str, int] = {}
    for img in data.get("images", []):
        stem = Path(img["file_name"]).stem
        stems[img["id"]] = stem
        image_ids[stem] = img["id"]

    annotation_ids: Dict[str, List[int]] = {stem: [] for stem in image_ids}
    classes: Dict[str, List[Optional[str]]] = {stem: [] for stem in image_ids}
    for ann in data.get("annotations", []):
        stem = stems.get(ann.get("image_id"))
        if stem is None:
            continue
        annotation_ids[stem].append(ann["id"])
        classes[stem].append(category_names.get(ann.get("category_id")))

    return CocoIdentity(
        image_ids=image_ids,
        annotation_ids=annotation_ids,
        classes=classes,
        info=data.get("info") or {},
        licenses=data.get("licenses") or [],
        supercategory=supercategories.pop() if len(supercategories) == 1 else None,
    )

check_coco_identity

check_coco_identity(identity: CocoIdentity, frames: Sequence[FrameAnnotations], names: Sequence[str]) -> List[str]

Whether identity really describes frames, row for row. Returns the problems found.

Checked before anything is written, because a positional map applied to the wrong rows produces a file that is wrong in a way nothing downstream can detect.

Source code in hbb2obb/formats.py
def check_coco_identity(identity: CocoIdentity, frames: Sequence[FrameAnnotations], names: Sequence[str]) -> List[str]:
    """
    Whether ``identity`` really describes ``frames``, row for row. Returns the problems found.

    Checked before anything is written, because a positional map applied to the wrong rows produces
    a file that is wrong in a way nothing downstream can detect.
    """
    problems = []
    missing = [f.stem for f in frames if f.stem not in identity.image_ids]
    if missing:
        problems.append(f"{len(missing)} frame(s) are not in the source file, e.g. {missing[:3]}")

    for frame in frames:
        if frame.stem not in identity.image_ids:
            continue
        ids = identity.annotation_ids[frame.stem]
        if len(ids) != len(frame.boxes):
            problems.append(f"{frame.stem}: {len(frame.boxes)} box(es) here against {len(ids)} in the source")
            continue
        for row, (box, name) in enumerate(zip(frame.boxes, identity.classes[frame.stem])):
            if name is not None and 0 <= box.cls < len(names) and names[box.cls] != name:
                problems.append(f"{frame.stem} row {row}: class {names[box.cls]!r} here against {name!r}")

    used = [i for f in frames if f.stem in identity.annotation_ids for i in identity.annotation_ids[f.stem]]
    if len(set(used)) != len(used):
        problems.append("the source file repeats an annotation id across the frames being written")
    return problems

Images

image_size

image_size(path: Path) -> Optional[Tuple[int, int]]

Read an image's (width, height) from its header, without decoding it.

Only the size is ever needed here, to denormalize relative coordinates and to fill in the formats that declare one, and decoding a directory of 4K frames to learn it is pure waste. Falls back to OpenCV for anything the three header layouts below do not cover.

A JPEG's Exif orientation is applied, because cv2.imread applies it too: the size reported here has to be the size of the frame the boxes were fitted in, or every coordinate normalized against it is divided by the wrong dimension.

Source code in hbb2obb/formats.py
def image_size(path: Path) -> Optional[Tuple[int, int]]:
    """
    Read an image's (width, height) from its header, without decoding it.

    Only the size is ever needed here, to denormalize relative coordinates and to fill in the
    formats that declare one, and decoding a directory of 4K frames to learn it is pure waste.
    Falls back to OpenCV for anything the three header layouts below do not cover.

    A JPEG's Exif orientation is applied, because ``cv2.imread`` applies it too: the size reported
    here has to be the size of the frame the boxes were fitted in, or every coordinate normalized
    against it is divided by the wrong dimension.
    """
    try:
        with open(path, "rb") as f:
            head = f.read(32)
            if head[:8] == b"\x89PNG\r\n\x1a\n":
                return (int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big"))
            if head[:2] == b"BM":
                # biHeight is negative for a top-down bitmap; the pixel height is its magnitude.
                width = int.from_bytes(head[18:22], "little", signed=True)
                height = int.from_bytes(head[22:26], "little", signed=True)
                return (abs(width), abs(height))
            if head[:2] == b"\xff\xd8":  # JPEG: walk the segments to the frame header
                f.seek(2)
                orientation = None
                while True:
                    marker = f.read(2)
                    if len(marker) < 2 or marker[0] != 0xFF:
                        break
                    if 0xC0 <= marker[1] <= 0xCF and marker[1] not in (0xC4, 0xC8, 0xCC):
                        f.read(3)  # segment length and sample precision
                        height = int.from_bytes(f.read(2), "big")
                        width = int.from_bytes(f.read(2), "big")
                        # Orientations 5 to 8 turn the frame a quarter turn, swapping its axes.
                        return (height, width) if orientation in (5, 6, 7, 8) else (width, height)
                    length = int.from_bytes(f.read(2), "big")
                    if length < 2:
                        break
                    if marker[1] == 0xE1 and orientation is None:  # APP1, where Exif lives
                        orientation = _exif_orientation(f.read(length - 2))
                        continue
                    f.seek(length - 2, 1)
    except (OSError, IndexError, ValueError):
        return None

    import cv2

    img = cv2.imread(str(path))
    return None if img is None else (img.shape[1], img.shape[0])