Files
2026-07-13 13:32:23 +08:00

201 lines
7.2 KiB
Python

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
from __future__ import annotations
import numpy as np
from PIL import Image
from rest_framework import status
from rest_framework.exceptions import ValidationError
class ROIHelper:
"""
Utilities for running automatic annotations functions on a cropped region of an image.
Public API requests pass ROI as image coordinates: [xtl, ytl, xbr, ybr].
Lambda functions still receive only an image, so the server crops the image
before invocation and then maps prompts/results between these coordinate
spaces.
"""
@classmethod
def parse_roi(cls, roi: list | None) -> dict | None:
"""
Validate request ROI and normalize it to a dict with explicit coordinate keys.
"""
if roi is None:
return None
if not isinstance(roi, list) or len(roi) != 4:
raise ValidationError(
"ROI must contain four integer coordinates: [xtl, ytl, xbr, ybr]",
code=status.HTTP_400_BAD_REQUEST,
)
if not all(
isinstance(coordinate, int) and not isinstance(coordinate, bool) for coordinate in roi
):
raise ValidationError(
"ROI must contain four integer coordinates: [xtl, ytl, xbr, ybr]",
code=status.HTTP_400_BAD_REQUEST,
)
xtl, ytl, xbr, ybr = roi
if xtl < 0 or ytl < 0 or xbr < 0 or ybr < 0:
raise ValidationError(
"ROI coordinates must be non-negative", code=status.HTTP_400_BAD_REQUEST
)
if xbr <= xtl or ybr <= ytl:
raise ValidationError("ROI size must be positive", code=status.HTTP_400_BAD_REQUEST)
return {"xtl": xtl, "ytl": ytl, "xbr": xbr, "ybr": ybr}
@staticmethod
def crop_image(image: Image.Image, roi: dict | None) -> Image.Image:
"""
Return an ROI crop when ROI is specified, otherwise return the source image.
"""
if roi is None:
return image
if roi["xbr"] > image.width or roi["ybr"] > image.height:
raise ValidationError("ROI is outside the image", code=status.HTTP_400_BAD_REQUEST)
cropped_image = image.crop((roi["xtl"], roi["ytl"], roi["xbr"], roi["ybr"]))
cropped_image.format = image.format
return cropped_image
@classmethod
def validate_task_roi(cls, task_id: int, roi: list | None) -> dict | None:
"""
Validate task-level ROI using stored frame dimensions, without decoding frame images.
"""
parsed_roi = cls.parse_roi(roi)
if parsed_roi is None:
return None
from cvat.apps.engine.models import MediaType, Task
db_task = Task.objects.select_related("data", "data__video").get(pk=task_id)
db_data = db_task.data
frames = [frame for frame in range(db_data.size) if frame not in db_data.deleted_frames]
if not frames:
return parsed_roi
if db_task.media_type == MediaType.IMAGE:
if db_video := getattr(db_data, "video", None):
sizes = {(db_video.width, db_video.height)}
else:
sizes = set(
db_data.images.filter(is_placeholder=False)
.exclude(frame__in=db_data.deleted_frames)
.values_list("width", "height")
.distinct()
)
else:
raise ValidationError(
"ROI is supported only for image and video tasks",
code=status.HTTP_400_BAD_REQUEST,
)
if len(sizes) != 1:
raise ValidationError(
"ROI can be used for task annotation only when all processed frames have the same resolution",
code=status.HTTP_400_BAD_REQUEST,
)
image_width, image_height = next(iter(sizes))
if parsed_roi["xbr"] > image_width or parsed_roi["ybr"] > image_height:
raise ValidationError("ROI is outside the image", code=status.HTTP_400_BAD_REQUEST)
return parsed_roi
@staticmethod
def _translate_anno_points(points, *, dx: int, dy: int):
"""
Translate a flat annotation point list: [x0, y0, x1, y1, ...].
This format is used by ordinary CVAT annotation shapes and mask bounds,
not by interactor prompts.
"""
return [coordinate + (dx if idx % 2 == 0 else dy) for idx, coordinate in enumerate(points)]
@staticmethod
def translate_prompt_points(points, *, dx: int, dy: int):
"""
Translate interactor prompt points between full-image and ROI-local space.
Interactor prompts use nested point pairs: [[x, y], ...]. This is
different from annotation shape points, which are flat coordinate lists.
"""
if points is None:
return None
try:
return [[x + dx, y + dy] for x, y in points]
except (TypeError, ValueError) as ex:
raise ValidationError("Interactor prompt points must contain point pairs") from ex
@classmethod
def translate_detector_shapes(cls, shapes, *, dx: int, dy: int):
"""
Translate detector result shapes in-place and return them.
Detector outputs and SAM plugin outputs can contain nested elements and
masks. All coordinate-bearing fields must move from ROI-local space back
to full-image space before CVAT stores or renders them.
"""
def translate_shape(annotation):
anno_type = annotation.get("type")
if anno_type == "tag":
return annotation
if anno_type != "mask" and "points" in annotation:
annotation["points"] = cls._translate_anno_points(
annotation["points"], dx=dx, dy=dy
)
if anno_type == "mask" and "points" in annotation:
annotation["points"] = [
*annotation["points"][:-4],
*cls._translate_anno_points(annotation["points"][-4:], dx=dx, dy=dy),
]
for element in annotation.get("elements", []):
translate_shape(element)
return annotation
for shape in shapes:
translate_shape(shape)
return shapes
@classmethod
def translate_interactor_response(
cls, response, *, roi: dict, image_width: int, image_height: int
):
"""
Move interactor results from ROI-local coordinates back to full image.
Interactors can return a dense mask or CVAT-like shapes. This method
keeps the response shape intact while translating the coordinate content
that was produced against the cropped ROI image.
"""
if "mask" in response:
crop_mask = np.array(response["mask"])
full_mask = np.zeros((image_height, image_width), dtype=crop_mask.dtype)
full_mask[roi["ytl"] : roi["ybr"], roi["xtl"] : roi["xbr"]] = crop_mask
response["mask"] = full_mask.tolist()
if "shapes" in response and isinstance(response["shapes"], list):
cls.translate_detector_shapes(response["shapes"], dx=roi["xtl"], dy=roi["ytl"])
return response