Skip to content

converter

The conversion behind the hbb2obb command: one image in, its OBBs out. How the conversion works describes the steps, and the guide has a worked example.

from pathlib import Path

from hbb2obb.converter import hbb2obb, save_obb_annotations

img_path = Path("data/images/04433.jpg")
obbs = hbb2obb(img_path, sam_models=["sam_l", "sam_b"], scale_factors=0.05)  # (N, 9) array
save_obb_annotations(obbs, Path("data/labels_obb"), img_path)

Converting an image

hbb2obb

hbb2obb(img_path: Path, hbb_dir: Path = None, sam_models: Union[str, List[str]] = 'sam_b.pt', imgsz: int = 1280, scale_factors: Union[float, Tuple[float, float], List[float]] = 0.05, opening_kernel_percentage: float = 0.15, fragment_ratio: float = DEFAULT_FRAGMENT_RATIO, save_img: bool = False, viz_dir: Path = None, show_hbb: bool = True, show_masks: bool = True, show_segments: bool = True, show_obb: bool = True, show_labels: bool = True, show_confidence: bool = False, model_kwargs: Dict[str, Any] = None, device: str = None, return_confidence: bool = False, confidence_source: str = 'conversion', return_contours: bool = False, return_stats: bool = False, models_dir: Optional[Path] = None) -> Union[ndarray, Tuple]

Convert HBB to OBB annotations using multiple SAM models and aggregating the masks by majority vote.

Parameters:

Name Type Description Default
img_path Path

Path to the image

required
hbb_dir Path

Directory containing HBB annotations

None
sam_models Union[str, List[str]]

Name(s) of SAM model(s) to use. Multiple models can be specified to average results

'sam_b.pt'
imgsz int

Image size for SAM model inference

1280
scale_factors Union[float, Tuple[float, float], List[float]]

Factor(s) to scale HBB bounding boxes. If single value: same factor for both dimensions. If two values: first for shorter side, second for longer side

0.05
opening_kernel_percentage float

Fraction of the mask's smaller dimension used as the morphological kernel. Positive opens (removes thin protrusions), negative closes (fills holes, rejoins fragments), 0 disables it

0.15
fragment_ratio float

Minimum area, as a fraction of the largest mask piece's, for another piece to be fitted together with it rather than discarded; 0 fits the largest piece alone

DEFAULT_FRAGMENT_RATIO
save_img bool

Save visualization images

False
viz_dir Path

Directory to save visualization images

None
show_hbb bool

Show horizontal bounding boxes

True
show_masks bool

Show segmentation masks

True
show_segments bool

Show segmentation contours

True
show_obb bool

Show oriented bounding boxes

True
show_labels bool

Show class labels

True
show_confidence bool

Print the per-OBB confidence score in the visualization

False
model_kwargs Dict[str, Any]

Additional keyword arguments for the SAM model

None
device str

Inference device, e.g. 'cpu', '0', 'cuda:0', 'mps' (default: ultralytics picks). An explicit 'device' in model_kwargs takes precedence.

None
return_confidence bool

If True, also return the per-OBB confidence scores

False
confidence_source str

Which score the returned confidences carry: 'conversion' (the heuristic conversion-quality score), 'detector' (the confidence read from the HBB input file), or 'combined' (their product)

'conversion'
return_contours bool

If True, also return the per-object segmentation contours, in absolute image pixel coordinates, with None where the OBB is a fallback HBB

False
return_stats bool

If True, also return a ConversionStats for this frame

False
models_dir Optional[Path]

Checkpoint directory (default: $HBB2OBB_MODELS_DIR, else models)

None

Returns:

Type Description
Union[ndarray, Tuple]

OBB annotations as a numpy array, with the requested extras appended in a tuple, in the order confidences, contours, stats. With no flag set, the array alone; with every flag, (obb_annotations, confidences, contours, stats).

The confidences and contours lists are always the same length as obb_annotations.

Source code in hbb2obb/converter.py
def hbb2obb(
    img_path: Path,
    hbb_dir: Path = None,
    sam_models: Union[str, List[str]] = "sam_b.pt",
    imgsz: int = 1280,
    scale_factors: Union[float, Tuple[float, float], List[float]] = 0.05,
    opening_kernel_percentage: float = 0.15,
    fragment_ratio: float = DEFAULT_FRAGMENT_RATIO,
    save_img: bool = False,
    viz_dir: Path = None,
    show_hbb: bool = True,
    show_masks: bool = True,
    show_segments: bool = True,
    show_obb: bool = True,
    show_labels: bool = True,
    show_confidence: bool = False,
    model_kwargs: Dict[str, Any] = None,
    device: str = None,
    return_confidence: bool = False,
    confidence_source: str = "conversion",
    return_contours: bool = False,
    return_stats: bool = False,
    models_dir: Optional[Path] = None,
) -> Union[np.ndarray, Tuple]:
    """
    Convert HBB to OBB annotations using multiple SAM models and aggregating the masks by majority vote.

    Args:
        img_path: Path to the image
        hbb_dir: Directory containing HBB annotations
        sam_models: Name(s) of SAM model(s) to use. Multiple models can be specified to average results
        imgsz: Image size for SAM model inference
        scale_factors: Factor(s) to scale HBB bounding boxes.
                     If single value: same factor for both dimensions.
                     If two values: first for shorter side, second for longer side
        opening_kernel_percentage: Fraction of the mask's smaller dimension used as the
                     morphological kernel. Positive opens (removes thin protrusions), negative
                     closes (fills holes, rejoins fragments), 0 disables it
        fragment_ratio: Minimum area, as a fraction of the largest mask piece's, for another
                     piece to be fitted together with it rather than discarded; 0 fits the
                     largest piece alone
        save_img: Save visualization images
        viz_dir: Directory to save visualization images
        show_hbb: Show horizontal bounding boxes
        show_masks: Show segmentation masks
        show_segments: Show segmentation contours
        show_obb: Show oriented bounding boxes
        show_labels: Show class labels
        show_confidence: Print the per-OBB confidence score in the visualization
        model_kwargs: Additional keyword arguments for the SAM model
        device: Inference device, e.g. 'cpu', '0', 'cuda:0', 'mps' (default: ultralytics picks).
                     An explicit 'device' in model_kwargs takes precedence.
        return_confidence: If True, also return the per-OBB confidence scores
        confidence_source: Which score the returned confidences carry: 'conversion' (the
                     heuristic conversion-quality score), 'detector' (the confidence read
                     from the HBB input file), or 'combined' (their product)
        return_contours: If True, also return the per-object segmentation contours, in absolute
                     image pixel coordinates, with None where the OBB is a fallback HBB
        return_stats: If True, also return a ``ConversionStats`` for this frame
        models_dir: Checkpoint directory (default: ``$HBB2OBB_MODELS_DIR``, else ``models``)

    Returns:
        OBB annotations as a numpy array, with the requested extras appended in a tuple, in the
        order confidences, contours, stats. With no flag set, the array alone; with every flag,
        ``(obb_annotations, confidences, contours, stats)``.

        The confidences and contours lists are always the same length as obb_annotations.
    """
    hbb_dir = get_hbb_dir(img_path, hbb_dir)

    # Read the image
    img = cv2.imread(str(img_path))

    # Load HBB annotations and scale them
    annotations = Annotations(hbb_dir / (img_path.stem + ".txt"), img)
    bbox_prompts = scale_bounding_boxes(annotations, scale_factors)

    # Nothing to convert: SAM cannot be prompted with zero boxes
    if len(bbox_prompts) == 0:
        empty = ConversionStats(boxes=0, fallbacks=0)
        return pack_results(np.array([]), [], [], empty, return_confidence, return_contours, return_stats)

    # Convert single model to list for consistent processing
    if isinstance(sam_models, str):
        sam_models = [sam_models]

    if model_kwargs is None:
        model_kwargs = {}
    if device is not None:
        # A new dict, so a model_kwargs shared across a directory of images is never mutated;
        # an explicit device in model_kwargs still wins.
        model_kwargs = {"device": device, **model_kwargs}
    masks_all_models = []
    masks_missing = 0
    inference_device = None

    # Run each model and collect results
    for model_name in sam_models:
        model = load_sam_model(model_name, models_dir=models_dir)

        # Run inference with the model
        results = model(
            img,
            bboxes=bbox_prompts[:, 1:],
            retina_masks=True,
            exist_ok=True,
            verbose=False,
            imgsz=imgsz,
            **model_kwargs,
        )

        result = results[0]
        if result.masks is not None:
            masks = result.masks.cpu().numpy()
            masks_all_models.append(masks.data)
            # Ultralytics drops any SAM mask whose predicted IoU is below `conf`, so a model can
            # answer a prompt set with fewer masks than it was given boxes and still not answer
            # with none. Every box left without a mask of its own falls back to its HBB, which
            # used to happen in silence: `result.masks` is a zero-length Masks object in that
            # case, not None, so the warning below never fired however many boxes were lost.
            shortfall = len(bbox_prompts) - len(masks.data)
            if shortfall > 0:
                masks_missing = max(masks_missing, shortfall)
                print(
                    f"Warning: model {model_name} returned {len(masks.data)} mask(s) for "
                    f"{len(bbox_prompts)} prompt(s) in {img_path.name}",
                    file=sys.stderr,
                )
        else:
            print(f"Warning: Model {model_name} produced no masks for {img_path.name}", file=sys.stderr)

        # The masks now live on the host, so release the device copies before prompting the next
        # model rather than at the end of the loop: an ensemble sweep keeps every member loaded
        # at once, and each one holding its last frame's full-resolution masks is several
        # gigabytes that the next model's image encoder needs for its own attention tensors.
        del result, results
        release_device_results(model)
        inference_device = getattr(getattr(model, "predictor", None), "device", inference_device)

    # Hand the device's reserved blocks back before the contour pass rather than after it: the
    # encoder activations are dead by now, and the pass that follows is the memory-hungry one.
    trim_device_cache(inference_device)

    # Convert segmentation masks within HBBs to OBB annotations. The masks are kept only when
    # something will draw them; a frame of 76 objects at 3840x2160 is over half a gigabyte of
    # full-frame bool array that a run without --save_img never looks at.
    obb_annotations, aggregated_masks, contours, confidences = create_obb_annotations_multi_model(
        bbox_prompts,
        masks_all_models,
        opening_kernel_percentage,
        fragment_ratio,
        keep_masks=save_img,
        fallback_boxes=annotations.hbb_xyxy,
    )
    del masks_all_models

    stats = ConversionStats(
        boxes=len(contours),
        fallbacks=sum(c is None for c in contours),
        masks_missing=masks_missing,
    )

    # Blend in the detector confidence from the HBB file, but only when a caller actually asked
    # for it: --confidence_source is documented as a no-op unless --save_confidence or
    # --show_confidence is used, and resolving it unconditionally would also print its "missing
    # confidence column" warning for runs that never look at the confidence at all.
    if return_confidence or show_confidence:
        confidences = resolve_confidences(confidences, annotations.hbb_scores, confidence_source, img_path)

    # Save visualization images if enabled
    if save_img:
        visualize_obb_annotations(
            img,
            bbox_prompts,
            aggregated_masks,
            contours,
            obb_annotations,
            viz_dir,
            img_path,
            show_hbb=show_hbb,
            show_masks=show_masks,
            show_segments=show_segments,
            show_obb=show_obb,
            show_labels=show_labels,
            confidences=confidences,
            show_confidence=show_confidence,
        )

    return pack_results(obb_annotations, confidences, contours, stats, return_confidence, return_contours, return_stats)

ConversionStats dataclass

What one frame's conversion produced, as opposed to what it was asked to produce.

A fallback box is one where no usable mask or contour was found and the source HBB was emitted unchanged. It is recoverable afterwards only from a 0.0 confidence, and only when the scores were written and --confidence_source left them alone, so a run that reports nothing leaves a dataset release recomputing a number it should have been handed.

masks_missing counts prompts that came back without a mask of their own: ultralytics drops a SAM mask whose predicted IoU is below conf, so a model can return fewer masks than it was given boxes without returning none at all. That is the case a whole frame of fallbacks looks like from the inside, and nothing used to say it had happened.

Source code in hbb2obb/converter.py
@dataclass(frozen=True)
class ConversionStats:
    """
    What one frame's conversion produced, as opposed to what it was asked to produce.

    A fallback box is one where no usable mask or contour was found and the source HBB was
    emitted unchanged. It is recoverable afterwards only from a 0.0 confidence, and only when
    the scores were written and ``--confidence_source`` left them alone, so a run that reports
    nothing leaves a dataset release recomputing a number it should have been handed.

    ``masks_missing`` counts prompts that came back without a mask of their own: ultralytics
    drops a SAM mask whose predicted IoU is below ``conf``, so a model can return fewer masks
    than it was given boxes without returning none at all. That is the case a whole frame of
    fallbacks looks like from the inside, and nothing used to say it had happened.
    """

    boxes: int
    fallbacks: int
    masks_missing: int = 0

    @property
    def share(self) -> float:
        """Fraction of this frame's boxes that fell back, 0.0 for a frame with no boxes."""
        return self.fallbacks / self.boxes if self.boxes else 0.0

share property

share: float

Fraction of this frame's boxes that fell back, 0.0 for a frame with no boxes.

Writing the results

save_obb_annotations

save_obb_annotations(obb_annotations: ndarray, obb_dir: Path, img_path: Path, confidences: List[float] = None, normalize: bool = False, img_shape: Tuple[int, int] = None, precision: int = DEFAULT_NORMALIZED_PRECISION)

Save OBB annotations to a text file.

Each line is class x1 y1 x2 y2 x3 y3 x4 y4, in absolute pixels unless normalize is set, in which case the eight coordinates are relative to the frame and Ultralytics reads the file unchanged. When confidences is provided, a 10th field with the per-OBB confidence score is appended (... x4 y4 conf).

Source code in hbb2obb/converter.py
def save_obb_annotations(
    obb_annotations: np.ndarray,
    obb_dir: Path,
    img_path: Path,
    confidences: List[float] = None,
    normalize: bool = False,
    img_shape: Tuple[int, int] = None,
    precision: int = DEFAULT_NORMALIZED_PRECISION,
):
    """
    Save OBB annotations to a text file.

    Each line is ``class x1 y1 x2 y2 x3 y3 x4 y4``, in absolute pixels unless ``normalize`` is
    set, in which case the eight coordinates are relative to the frame and Ultralytics reads the
    file unchanged. When ``confidences`` is provided, a 10th field with the per-OBB confidence
    score is appended (``... x4 y4 conf``).
    """
    obb_dir = resolve_output_dir(obb_dir, img_path, "labels_obb")
    save_filepath = obb_dir / (img_path.stem + ".txt")

    with open(save_filepath, "w", encoding="utf-8") as f:
        for i, obb in enumerate(obb_annotations):
            line = f"{int(obb[0])} " + coordinate_fields(obb[1:], normalize, img_shape, precision)
            f.write(format_annotation_line(line, confidences, i) + "\n")

save_confidence_annotations

save_confidence_annotations(confidences: List[float], confidence_dir: Path, img_path: Path) -> None

Save the per-object confidence scores to their own file, one score per line.

Row-aligned with the OBB file for the same image: line i of both describes the same object. This is the alternative to the 10th column save_obb_annotations writes, for annotation sets whose label files have to stay strictly standard. Ultralytics, for one, rejects a 10th column on a YOLO OBB label, so a release that wants both a usable label file and a per-box score has to put the score somewhere else.

Source code in hbb2obb/converter.py
def save_confidence_annotations(confidences: List[float], confidence_dir: Path, img_path: Path) -> None:
    """
    Save the per-object confidence scores to their own file, one score per line.

    Row-aligned with the OBB file for the same image: line i of both describes the same object.
    This is the alternative to the 10th column ``save_obb_annotations`` writes, for annotation
    sets whose label files have to stay strictly standard. Ultralytics, for one, rejects a 10th
    column on a YOLO OBB label, so a release that wants both a usable label file and a per-box
    score has to put the score somewhere else.
    """
    confidence_dir = resolve_output_dir(confidence_dir, img_path, "labels_confidence")
    with open(confidence_dir / (img_path.stem + ".txt"), "w", encoding="utf-8") as f:
        for score in confidences:
            f.write(f"{score:.4f}\n")

save_polygon_annotations

save_polygon_annotations(contours: List[ndarray], obb_annotations: ndarray, polygon_dir: Path, img_path: Path, confidences: List[float] = None, simplify_epsilon: float = 0.0, normalize: bool = False, img_shape: Tuple[int, int] = None, precision: int = DEFAULT_NORMALIZED_PRECISION) -> None

Save the segmentation polygons to a text file, as a tighter alternative to the OBBs.

Each line is class x1 y1 x2 y2 ... xN yN, the corners in absolute pixel coordinates, the same convention save_obb_annotations uses. When confidences is provided, the per-object score is appended as a final field, exactly as the OBB writer does.

Row alignment is guaranteed: the file holds exactly one line per OBB, in the same order, so line i here and line i of the OBB file for the same image describe the same object. Objects that fell back to the HBB (no usable mask, contour None) are written as the four corners of their OBB row rather than skipped, which makes a fallback recognizable as a four-point polygon identical to its OBB line.

Parameters:

Name Type Description Default
contours List[ndarray]

Per-object contours from hbb2obb(..., return_contours=True), None for fallbacks

required
obb_annotations ndarray

Matching OBB annotations, supplying the class label and the fallback corners

required
polygon_dir Path

Directory to save the polygon annotations

required
img_path Path

Image the annotations belong to, used to derive the output file name

required
confidences List[float]

Per-object confidence scores, written as a trailing field

None
simplify_epsilon float

If > 0, simplify each contour with cv2.approxPolyDP using an epsilon of this fraction of the contour perimeter (cv2.arcLength), so one value behaves consistently across object sizes; 0.005 to 0.02 are typical. The default 0 writes the raw contour. Fallback rectangles are never simplified.

0.0
normalize bool

Write coordinates relative to the frame size instead of in pixels. Follows the OBB file it sits beside, so one directory never mixes the two conventions.

False
img_shape Tuple[int, int]

(width, height) of the frame, required when normalize is set

None
precision int

Decimal places for normalized output; ignored for absolute output

DEFAULT_NORMALIZED_PRECISION
Source code in hbb2obb/converter.py
def save_polygon_annotations(
    contours: List[np.ndarray],
    obb_annotations: np.ndarray,
    polygon_dir: Path,
    img_path: Path,
    confidences: List[float] = None,
    simplify_epsilon: float = 0.0,
    normalize: bool = False,
    img_shape: Tuple[int, int] = None,
    precision: int = DEFAULT_NORMALIZED_PRECISION,
) -> None:
    """
    Save the segmentation polygons to a text file, as a tighter alternative to the OBBs.

    Each line is ``class x1 y1 x2 y2 ... xN yN``, the corners in absolute pixel coordinates,
    the same convention ``save_obb_annotations`` uses. When ``confidences`` is provided, the
    per-object score is appended as a final field, exactly as the OBB writer does.

    Row alignment is guaranteed: the file holds exactly one line per OBB, in the same order,
    so line i here and line i of the OBB file for the same image describe the same object.
    Objects that fell back to the HBB (no usable mask, contour ``None``) are written as the
    four corners of their OBB row rather than skipped, which makes a fallback recognizable
    as a four-point polygon identical to its OBB line.

    Args:
        contours: Per-object contours from hbb2obb(..., return_contours=True), None for fallbacks
        obb_annotations: Matching OBB annotations, supplying the class label and the fallback corners
        polygon_dir: Directory to save the polygon annotations
        img_path: Image the annotations belong to, used to derive the output file name
        confidences: Per-object confidence scores, written as a trailing field
        simplify_epsilon: If > 0, simplify each contour with cv2.approxPolyDP using an epsilon of
                     this fraction of the contour perimeter (cv2.arcLength), so one value behaves
                     consistently across object sizes; 0.005 to 0.02 are typical. The default 0
                     writes the raw contour. Fallback rectangles are never simplified.
        normalize: Write coordinates relative to the frame size instead of in pixels. Follows the
                     OBB file it sits beside, so one directory never mixes the two conventions.
        img_shape: (width, height) of the frame, required when ``normalize`` is set
        precision: Decimal places for normalized output; ignored for absolute output
    """
    if contours is None or len(contours) != len(obb_annotations):
        raise ValueError(
            f"Expected one contour per OBB, got {len(contours) if contours is not None else None} "
            f"contours for {len(obb_annotations)} OBBs"
        )

    polygon_dir = resolve_output_dir(polygon_dir, img_path, "labels_polygon")
    save_filepath = polygon_dir / (img_path.stem + ".txt")

    with open(save_filepath, "w", encoding="utf-8") as f:
        for i, obb in enumerate(obb_annotations):
            if contours[i] is None:
                # Fallback: reuse the OBB corners so both files stay in agreement. Left at full
                # precision, since coordinate_fields is what turns absolute output integral and
                # rounding here first would put the normalized polygon a pixel off its own OBB.
                points = np.asarray(obb[1:], dtype=np.float64).reshape(-1, 2)
            else:
                points = simplify_contour(contours[i], simplify_epsilon)
            line = f"{int(obb[0])} " + coordinate_fields(
                np.asarray(points).reshape(-1), normalize, img_shape, precision
            )
            f.write(format_annotation_line(line, confidences, i) + "\n")

Pipeline steps

scale_bounding_boxes

scale_bounding_boxes(annotations: Annotations, factors: Union[float, Tuple[float, float], List[float]]) -> ndarray

Scale bounding boxes according to specified factors while ensuring they stay within image dimensions.

Parameters:

Name Type Description Default
annotations Annotations

Annotations object containing bounding boxes and image dimensions

required
factors Union[float, Tuple[float, float], List[float]]

Scale factor(s). If a single value, both dimensions are scaled by the same factor. If two values, the first applies to the shorter side and the second to the longer side.

required

Returns:

Type Description
ndarray

Scaled bounding boxes

Source code in hbb2obb/converter.py
def scale_bounding_boxes(
    annotations: Annotations, factors: Union[float, Tuple[float, float], List[float]]
) -> np.ndarray:
    """
    Scale bounding boxes according to specified factors while ensuring they stay within image dimensions.

    Args:
        annotations: Annotations object containing bounding boxes and image dimensions
        factors: Scale factor(s).
                If a single value, both dimensions are scaled by the same factor.
                If two values, the first applies to the shorter side and the second to the longer side.

    Returns:
        Scaled bounding boxes
    """
    boxes = annotations.hbb_xyxy
    width, height = annotations.img_shape

    # Convert factors to a tuple with two values
    if isinstance(factors, (list, tuple)):
        if len(factors) > 2:
            short_factor, long_factor = factors[0], factors[1]
            print(f"Warning: More than two scale factors provided. Using the first two: {short_factor}, {long_factor}")
        elif len(factors) == 2:
            short_factor, long_factor = factors[0], factors[1]
        elif len(factors) == 1:
            short_factor = long_factor = factors[0]
        else:
            short_factor = long_factor = 0.05
    else:
        short_factor = long_factor = float(factors)

    scaled_bounding_boxes = []
    for box in boxes:
        label, x1, y1, x2, y2 = box
        w = x2 - x1
        h = y2 - y1

        # Determine which factor to use for each dimension
        if w < h:
            w_factor, h_factor = short_factor, long_factor
        else:
            w_factor, h_factor = long_factor, short_factor

        # Apply the appropriate factors to each side
        x1 = max(0, x1 - w * w_factor)
        y1 = max(0, y1 - h * h_factor)
        x2 = min(width - 1, x2 + w * w_factor)
        y2 = min(height - 1, y2 + h * h_factor)

        scaled_bounding_boxes.append([label, x1, y1, x2, y2])

    return np.array(scaled_bounding_boxes)

aggregate_masks_by_majority_vote

aggregate_masks_by_majority_vote(masks: List[ndarray]) -> ndarray

Aggregate multiple masks using majority voting. For each pixel, it's kept if it's confirmed by the majority of models.

Parameters:

Name Type Description Default
masks List[ndarray]

List of boolean masks to aggregate

required

Returns:

Type Description
ndarray

Aggregated boolean mask

Source code in hbb2obb/converter.py
def aggregate_masks_by_majority_vote(masks: List[np.ndarray]) -> np.ndarray:
    """
    Aggregate multiple masks using majority voting.
    For each pixel, it's kept if it's confirmed by the majority of models.

    Args:
        masks: List of boolean masks to aggregate

    Returns:
        Aggregated boolean mask
    """
    # Stack masks along a new axis
    stacked_masks = np.stack(masks, axis=0)

    # Determine threshold for majority
    threshold = len(masks) // 2 + 1

    # Apply majority voting: sum across models and threshold
    aggregated_mask = np.sum(stacked_masks, axis=0) >= threshold

    return aggregated_mask

apply_morphological_opening

apply_morphological_opening(mask: ndarray, kernel_percentage: float) -> ndarray

Applies a morphological opening or closing to a boolean mask.

The sign of kernel_percentage selects the operation and its magnitude sets the kernel size, so one axis covers both directions and zero is the "off" point in the middle.

Positive is an opening (erode, then dilate), which removes thin protrusions and small specks. It can only take pixels away, so it tightens a mask that has bled into the road or into a neighbouring vehicle.

Negative is a closing (dilate, then erode), which fills holes and joins fragments. It can only add pixels. That matters here because the caller keeps the largest contour only: a vehicle split in two by windscreen glare, a roof-colour break or an occluding pole loses the smaller piece for good, and a closing is the only step in the pipeline that can put the two back together before that choice is made. Its risk is the mirror image of opening's: it can also reach across a narrow gap to a neighbour, bounded by the clip to the expanded HBB.

Parameters:

Name Type Description Default
mask ndarray

The input boolean mask (True for foreground) or None.

required
kernel_percentage float

Fraction of the mask's smaller dimension to use as kernel size. Positive opens, negative closes, and 0 returns the mask unchanged, as does a None or empty mask.

required

Returns:

Type Description
ndarray

The processed boolean mask, or None if the input was None.

Source code in hbb2obb/converter.py
def apply_morphological_opening(mask: np.ndarray, kernel_percentage: float) -> np.ndarray:
    """
    Applies a morphological opening or closing to a boolean mask.

    The sign of ``kernel_percentage`` selects the operation and its magnitude sets the kernel
    size, so one axis covers both directions and zero is the "off" point in the middle.

    **Positive is an opening** (erode, then dilate), which removes thin protrusions and small
    specks. It can only take pixels away, so it tightens a mask that has bled into the road or
    into a neighbouring vehicle.

    **Negative is a closing** (dilate, then erode), which fills holes and joins fragments. It can
    only add pixels. That matters here because the caller keeps the *largest contour only*: a
    vehicle split in two by windscreen glare, a roof-colour break or an occluding pole loses the
    smaller piece for good, and a closing is the only step in the pipeline that can put the two
    back together before that choice is made. Its risk is the mirror image of opening's: it can
    also reach across a narrow gap to a neighbour, bounded by the clip to the expanded HBB.

    Args:
        mask: The input boolean mask (True for foreground) or None.
        kernel_percentage: Fraction of the mask's smaller dimension to use as kernel size.
                        Positive opens, negative closes, and 0 returns the mask unchanged, as
                        does a None or empty mask.

    Returns:
        The processed boolean mask, or None if the input was None.
    """
    # Return immediately if morphology is disabled or the mask is invalid/None
    if kernel_percentage == 0 or mask is None or mask.size == 0 or not mask.any():
        return mask

    # Ensure mask is boolean before converting to uint8
    if mask.dtype != bool:
        mask = mask.astype(bool)

    # Calculate kernel size as a percentage of the smaller dimension of the bounding box
    contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return mask

    # Get the bounding box of the largest contour
    w, h = cv2.boundingRect(max(contours, key=cv2.contourArea))[2:4]

    # Calculate the smaller dimension of the bounding box
    smaller_dim = min(w, h)

    # Calculate the kernel size from the magnitude; the sign chose the operation, not the size
    kernel_size = max(1, int(smaller_dim * abs(kernel_percentage)))

    # Ensure kernel size is odd
    kernel_size = kernel_size + 1 if kernel_size % 2 == 0 else kernel_size

    # Ensure kernel size is at least 3x3
    kernel_size = max(3, kernel_size)

    # Create the kernel
    kernel = np.ones((kernel_size, kernel_size), np.uint8)

    # Convert boolean mask to uint8 (0 and 255) for OpenCV function
    mask_uint8 = mask.astype(np.uint8) * 255

    # Apply the operation the sign selected
    operation = cv2.MORPH_OPEN if kernel_percentage > 0 else cv2.MORPH_CLOSE
    opened_mask_uint8 = cv2.morphologyEx(mask_uint8, operation, kernel)

    # Convert back to boolean mask
    processed_mask = opened_mask_uint8 > 0

    return processed_mask

merge_fragment_contours

merge_fragment_contours(contours: Sequence[ndarray], largest: ndarray, fragment_ratio: float) -> List[ndarray]

The contours to fit the rotated box to: the largest one, plus any other piece holding at least fragment_ratio of its area.

Closing the mask rejoins fragments that are close enough to bridge; this catches the ones that are not, where the occluder is wider than the kernel. Everything here is already clipped to the expanded HBB, which is what bounds how far a merge can reach.

Parameters:

Name Type Description Default
contours Sequence[ndarray]

All contours found in the mask, in any order

required
largest ndarray

The largest valid contour, which is always kept

required
fragment_ratio float

Minimum area of a second piece as a fraction of the largest one's; 0 or less keeps the largest contour alone

required

Returns:

Type Description
List[ndarray]

The contours to fit together, always beginning with largest.

Source code in hbb2obb/converter.py
def merge_fragment_contours(
    contours: Sequence[np.ndarray], largest: np.ndarray, fragment_ratio: float
) -> List[np.ndarray]:
    """
    The contours to fit the rotated box to: the largest one, plus any other piece holding at
    least ``fragment_ratio`` of its area.

    Closing the mask rejoins fragments that are close enough to bridge; this catches the ones
    that are not, where the occluder is wider than the kernel. Everything here is already
    clipped to the expanded HBB, which is what bounds how far a merge can reach.

    Args:
        contours: All contours found in the mask, in any order
        largest: The largest valid contour, which is always kept
        fragment_ratio: Minimum area of a second piece as a fraction of the largest one's;
                        0 or less keeps the largest contour alone

    Returns:
        The contours to fit together, always beginning with ``largest``.
    """
    if fragment_ratio <= 0:
        return [largest]
    floor = fragment_ratio * cv2.contourArea(largest)
    return [largest] + [c for c in contours if c is not largest and cv2.contourArea(c) >= floor]

Models

load_sam_model

load_sam_model(model_name: str, models_dir: Optional[Path] = None)

Load a SAM/FastSAM model by name, reusing a cached instance when one is already loaded.

Parameters:

Name Type Description Default
model_name str

Model name (e.g. "sam_b", "sam2.1_b", "FastSAM-s"), with or without a ".pt" suffix.

required
models_dir Optional[Path]

Checkpoint directory (default: $HBB2OBB_MODELS_DIR, else models)

None

Returns:

Type Description

The loaded ultralytics SAM or FastSAM model instance.

Source code in hbb2obb/converter.py
def load_sam_model(model_name: str, models_dir: Optional[Path] = None):
    """
    Load a SAM/FastSAM model by name, reusing a cached instance when one is already loaded.

    Args:
        model_name: Model name (e.g. "sam_b", "sam2.1_b", "FastSAM-s"), with or without a ".pt" suffix.
        models_dir: Checkpoint directory (default: ``$HBB2OBB_MODELS_DIR``, else ``models``)

    Returns:
        The loaded ultralytics SAM or FastSAM model instance.
    """
    model_path = sam_checkpoint_path(model_name, models_dir)
    cache_key = str(model_path)
    if cache_key not in _MODEL_CACHE:
        _MODEL_CACHE[cache_key] = FastSAM(model_path) if "FastSAM" in model_name else SAM(model_path)
    return _MODEL_CACHE[cache_key]

clear_model_cache

clear_model_cache() -> None

Clear the in-process SAM/FastSAM model cache, releasing the associated memory.

Dropping the last reference frees the weights, but on CUDA the caching allocator keeps the freed blocks reserved, shaped like the checkpoints that just went away. The next ensemble then meets a fragmented heap rather than an empty one, which is what stops a SAM image encoder from allocating the multi-gigabyte contiguous attention tensors it asks for. A benchmark clears the cache between runs precisely so the later, larger runs start clean.

Metal keeps freed blocks reserved the same way, so an Apple Silicon run needs the same release; torch.mps is always importable and empty_cache() is a no-op where no Metal device was ever used, so the branch costs nothing off that hardware.

Source code in hbb2obb/converter.py
def clear_model_cache() -> None:
    """
    Clear the in-process SAM/FastSAM model cache, releasing the associated memory.

    Dropping the last reference frees the weights, but on CUDA the caching allocator keeps the
    freed blocks reserved, shaped like the checkpoints that just went away. The next ensemble
    then meets a fragmented heap rather than an empty one, which is what stops a SAM image
    encoder from allocating the multi-gigabyte contiguous attention tensors it asks for. A
    benchmark clears the cache between runs precisely so the later, larger runs start clean.

    Metal keeps freed blocks reserved the same way, so an Apple Silicon run needs the same
    release; ``torch.mps`` is always importable and ``empty_cache()`` is a no-op where no
    Metal device was ever used, so the branch costs nothing off that hardware.
    """
    import torch

    _MODEL_CACHE.clear()
    if torch.cuda.is_available():
        torch.cuda.empty_cache()
    if torch.backends.mps.is_available():
        torch.mps.empty_cache()