0.1.39
Loading...
Searching...
No Matches
annotation.py
Go to the documentation of this file.
1"""
2Image annotation from a pixel-to-primitive map.
3
4These functions turn a per-pixel record of which Context primitive is visible, together with
5labels stored as primitive or object data, into bounding boxes (Ultralytics YOLO format) and
6segmentation masks (COCO JSON format). They do not depend on how the image was produced, so any
7renderer that can say which primitive is in each pixel writes the same annotations for the same
8labeled Context. The radiation and visualizer plug-ins use the same routines for their own
9annotation methods (:meth:`pyhelios.RadiationModel.writeImageBoundingBoxes`,
10:meth:`pyhelios.Visualizer.writeImageBoundingBoxes`, and their segmentation-mask counterparts).
11
12**The pixel-to-primitive map** is a sequence of ``width * height`` unsigned integers, row-major,
13with row 0 at the TOP of the image. Each element is the UUID of the primitive visible in that
14pixel **plus one**; 0 means no primitive is visible. A nested list, a flat list or a NumPy array
15of either shape is accepted. :meth:`pyhelios.Visualizer.getPixelUUIDs` returns a map in exactly
16this form.
17
18**Labels** are primitive (or, with ``use_object_data=True``, object) data of type ``uint`` or
19``int``. Every primitive carrying the same value of the same data label belongs to one object.
20Primitives without the label are not annotated.
21
22Example:
23 >>> from pyhelios import Context, annotation
24 >>> from pyhelios.types import int2
25 >>> # pixel_uuids, width, height = visualizer.getPixelUUIDs()
26 >>> boxes = annotation.labelBoundingBoxes(context, pixel_uuids, int2(width, height),
27 ... "leaf_id", 0)
28 >>> annotation.writeLabelBoundingBoxes(context, pixel_uuids, int2(width, height),
29 ... "leaf_id", 0, image_file="scene.jpeg")
30"""
31
32import numbers
33from dataclasses import dataclass
34from typing import Dict, List, Optional, Sequence, Tuple, Union
35
36import numpy as np
37
38from .Context import Context
39from .wrappers import UContextWrapper as context_wrapper
40from .wrappers.DataTypes import int2, vec2
41
42__all__ = [
43 "YOLOBox",
44 "labelBoundingBoxes",
45 "writeLabelBoundingBoxes",
46 "labelMasks",
47 "writeLabelSegmentationMasks",
48]
49
50
51@dataclass
52class YOLOBox:
53 """A bounding box in the normalized form of the YOLO annotation format.
54
55 Attributes:
56 class_id: Class index of the object.
57 center: Center of the box, normalized to [0,1] by the image dimensions, origin at the top-left.
58 size: Width and height of the box, normalized to [0,1] by the image dimensions.
59 """
60 class_id: int
61 center: vec2
62 size: vec2
63
64
65def _check_context(context, func_name: str) -> None:
66 if not isinstance(context, Context):
67 raise ValueError(f"{func_name}: context must be a Context, got {type(context).__name__}")
68 context._check_context_available()
69
70
71def _check_resolution(resolution, func_name: str) -> Tuple[int, int]:
72 if not isinstance(resolution, int2):
73 raise ValueError(f"{func_name}: resolution must be an int2, got {type(resolution).__name__}")
74 if resolution.x <= 0 or resolution.y <= 0:
75 raise ValueError(f"{func_name}: resolution must be positive, got ({resolution.x}, {resolution.y})")
76 return int(resolution.x), int(resolution.y)
78
79def _check_pixel_map(pixel_uuids, width: int, height: int, func_name: str) -> np.ndarray:
80 if isinstance(pixel_uuids, (str, bytes)) or not isinstance(pixel_uuids, (Sequence, np.ndarray)):
81 raise TypeError(f"{func_name}: pixel_uuids must be a list or NumPy array of integers, "
82 f"got {type(pixel_uuids).__name__}")
83 arr = np.asarray(pixel_uuids)
84 if arr.size > 0 and arr.dtype.kind not in "iu":
85 raise TypeError(f"{func_name}: pixel_uuids must hold integers (UUID + 1 per pixel, 0 for none), "
86 f"got dtype {arr.dtype}")
87 if arr.size != width * height:
88 raise ValueError(f"{func_name}: the pixel-to-primitive map has {arr.size} elements, which does not "
89 f"match the image resolution of {width} x {height} ({width * height} pixels)")
90 if arr.size > 0 and (arr.min() < 0 or arr.max() > 0xFFFFFFFF):
91 raise ValueError(f"{func_name}: pixel_uuids values must be in [0, 2^32 - 1] (UUID + 1 per pixel, 0 for none)")
92 return arr.reshape(-1)
93
94
95def _check_labels(data_labels, class_ids, func_name: str) -> Tuple[List[str], List[int]]:
96 if isinstance(data_labels, str):
97 if not isinstance(class_ids, numbers.Integral) or isinstance(class_ids, bool):
98 raise TypeError(f"{func_name}: for a single data label, class_ids must be an integer, "
99 f"got {type(class_ids).__name__}")
100 data_labels, class_ids = [data_labels], [class_ids]
101 elif isinstance(data_labels, (list, tuple)):
102 if not isinstance(class_ids, (list, tuple)):
103 raise TypeError(f"{func_name}: for a list of data labels, class_ids must be a list of integers, "
104 f"got {type(class_ids).__name__}")
105 if len(data_labels) != len(class_ids):
106 raise ValueError(f"{func_name}: data_labels and class_ids must have the same length "
107 f"({len(data_labels)} != {len(class_ids)})")
108 if len(data_labels) == 0:
109 raise ValueError(f"{func_name}: at least one data label is required")
110 else:
111 raise TypeError(f"{func_name}: data_labels must be a string or a list of strings, "
112 f"got {type(data_labels).__name__}")
113 for label in data_labels:
114 if not isinstance(label, str) or not label.strip():
115 raise TypeError(f"{func_name}: data labels must be non-empty strings, got {label!r}")
116 for cid in class_ids:
117 if not isinstance(cid, numbers.Integral) or isinstance(cid, bool) or cid < 0:
118 raise ValueError(f"{func_name}: class IDs must be non-negative integers, got {cid!r}")
119 return list(data_labels), [int(c) for c in class_ids]
120
121
122def _check_filename(value, name: str, func_name: str) -> str:
123 if not isinstance(value, str) or not value.strip():
124 raise TypeError(f"{func_name}: {name} must be a non-empty string")
125 return value
126
127
128def labelBoundingBoxes(context: Context, pixel_uuids, resolution: int2,
129 data_labels: Union[str, List[str]], class_ids: Union[int, List[int]],
130 use_object_data: bool = False) -> List[YOLOBox]:
131 """
132 Compute a bounding box for each labeled object visible in a pixel-to-primitive map.
133
134 One box is produced per distinct pair of class index and label value. A box encloses whole
135 every pixel in which the object is visible: it runs from the left edge of the first pixel
136 column to the right edge of the last, and from the top edge of the first pixel row to the
137 bottom edge of the last, so an object visible in a single pixel has a box one pixel in size.
138
139 Args:
140 context: Context holding the primitives named by the map and their label data.
141 pixel_uuids: Pixel-to-primitive map (UUID + 1 per pixel, 0 for none), row-major with
142 row 0 at the top of the image.
143 resolution: Image dimensions in pixels, as ``int2(width, height)``.
144 data_labels: Name of a primitive or object data label, or a list of them.
145 class_ids: Class index for the objects of each data label (an int for a single label, or a
146 list in the same order as ``data_labels``).
147 use_object_data: If True, labels are read from the data of each primitive's parent
148 object; if False (default), from primitive data.
149
150 Returns:
151 List of :class:`YOLOBox`, ordered by class index and then label value.
152
153 Raises:
154 ValueError: If ``context`` is not a Context, ``resolution`` is not an int2, or the map
155 does not have ``width * height`` elements.
156 TypeError: If the labels, class IDs or map have the wrong type.
157 NotImplementedError: If the native library predates helios-core v1.3.91.
158 """
159 func_name = "labelBoundingBoxes"
160 _check_context(context, func_name)
161 width, height = _check_resolution(resolution, func_name)
162 pixels = _check_pixel_map(pixel_uuids, width, height, func_name)
163 labels, ids = _check_labels(data_labels, class_ids, func_name)
164 boxes = context_wrapper.annotationLabelBoundingBoxesWrapper(
165 context.getNativePtr(), pixels, width, height, labels, ids, bool(use_object_data))
166 return [YOLOBox(class_id=cid, center=vec2(cx, cy), size=vec2(w, h)) for cid, cx, cy, w, h in boxes]
167
168
169def writeLabelBoundingBoxes(context: Context, pixel_uuids, resolution: int2,
170 data_labels: Union[str, List[str]], class_ids: Union[int, List[int]],
171 image_file: str, classes_txt_file: str = "classes.txt",
172 image_path: str = "./", use_object_data: bool = False) -> None:
173 """
174 Write YOLO bounding boxes and a class file for the labeled objects visible in a pixel-to-primitive map.
175
176 The boxes (see :func:`labelBoundingBoxes`) are written to a file named after the image, with
177 its extension replaced by ``.txt``, in the directory ``image_path``. The class file holds one
178 ``class_id data_label`` pair per line.
179
180 Args:
181 context: Context holding the primitives named by the map and their label data.
182 pixel_uuids: Pixel-to-primitive map (UUID + 1 per pixel, 0 for none), row-major with
183 row 0 at the top of the image.
184 resolution: Image dimensions in pixels, as ``int2(width, height)``.
185 data_labels: Name of a primitive or object data label, or a list of them.
186 class_ids: Class index for the objects of each data label.
187 image_file: Name of the image these boxes annotate. Only its stem is used, to name the
188 output file; the image itself need not exist.
189 classes_txt_file: Name of the class file to write within ``image_path`` (default "classes.txt").
190 image_path: Directory to write the output files to (default: current directory).
191 use_object_data: If True, labels are read from object data; if False (default), from primitive data.
192
193 Raises:
194 ValueError: On invalid arguments (see :func:`labelBoundingBoxes`).
195 HeliosRuntimeError: If ``image_path`` is not a writable directory or a file cannot be written.
196 NotImplementedError: If the native library predates helios-core v1.3.91.
197 """
198 func_name = "writeLabelBoundingBoxes"
199 _check_context(context, func_name)
200 width, height = _check_resolution(resolution, func_name)
201 pixels = _check_pixel_map(pixel_uuids, width, height, func_name)
202 labels, ids = _check_labels(data_labels, class_ids, func_name)
203 _check_filename(image_file, "image_file", func_name)
204 _check_filename(classes_txt_file, "classes_txt_file", func_name)
205 if not isinstance(image_path, str):
206 raise TypeError(f"{func_name}: image_path must be a string")
207 context_wrapper.annotationWriteLabelBoundingBoxesWrapper(
208 context.getNativePtr(), pixels, width, height, labels, ids, image_file, classes_txt_file,
209 image_path, bool(use_object_data))
210
211
212def labelMasks(context: Context, pixel_uuids, resolution: int2, data_label: str,
213 use_object_data: bool = False) -> Dict[int, np.ndarray]:
214 """
215 Build a binary mask for each distinct value of a data label visible in a pixel-to-primitive map.
216
217 Args:
218 context: Context holding the primitives named by the map and their label data.
219 pixel_uuids: Pixel-to-primitive map (UUID + 1 per pixel, 0 for none), row-major with
220 row 0 at the top of the image.
221 resolution: Image dimensions in pixels, as ``int2(width, height)``.
222 data_label: Name of the primitive or object data label, of type ``uint`` or ``int``.
223 use_object_data: If True, the label is read from object data; if False (default), from primitive data.
224
225 Returns:
226 Dict mapping each label value to a boolean NumPy array of shape ``(height, width)``,
227 indexed ``[row, column]`` with row 0 at the top of the image.
228
229 Raises:
230 ValueError: On invalid arguments (see :func:`labelBoundingBoxes`).
231 NotImplementedError: If the native library predates helios-core v1.3.91.
232 """
233 func_name = "labelMasks"
234 _check_context(context, func_name)
235 width, height = _check_resolution(resolution, func_name)
236 pixels = _check_pixel_map(pixel_uuids, width, height, func_name)
237 if not isinstance(data_label, str) or not data_label.strip():
238 raise TypeError(f"{func_name}: data_label must be a non-empty string")
239 values, masks = context_wrapper.annotationLabelMasksWrapper(
240 context.getNativePtr(), pixels, width, height, data_label, bool(use_object_data))
241 return {value: masks[i] for i, value in enumerate(values)}
242
243
244def writeLabelSegmentationMasks(context: Context, pixel_uuids, resolution: int2,
245 data_labels: Union[str, List[str]], class_ids: Union[int, List[int]],
246 json_filename: str, image_file: str,
247 data_attribute_labels: Optional[List[str]] = None,
248 append_file: bool = False, use_object_data: bool = False) -> None:
249 """
250 Write COCO JSON segmentation masks for the labeled objects visible in a pixel-to-primitive map.
251
252 Each connected region of pixels sharing one value of a data label becomes one annotation, so
253 an object split in two by an occluder yields two annotations. Pixels touching only at a corner
254 are connected. The ``bbox`` of an annotation is (x, y, width, height) in pixels, enclosing
255 whole every pixel of the region. A region of only one or two pixels is too small to outline
256 and is not written. Each data label becomes a COCO category.
257
258 Args:
259 context: Context holding the primitives named by the map and their label data.
260 pixel_uuids: Pixel-to-primitive map (UUID + 1 per pixel, 0 for none), row-major with
261 row 0 at the top of the image.
262 resolution: Image dimensions in pixels, as ``int2(width, height)``.
263 data_labels: Name of a primitive or object data label, or a list of them.
264 class_ids: COCO category ID for each data label.
265 json_filename: Path of the COCO JSON file to write. ".json" is appended if missing.
266 image_file: Path of the image these masks annotate, which must exist.
267 data_attribute_labels: Optional primitive or object data labels of type int, uint, float
268 or double whose mean over the pixels of each mask is written to that annotation's
269 "attributes". A label that exists as both is read from primitive data; labels that do
270 not exist are ignored.
271 append_file: If True, annotations are added to an existing file at ``json_filename``;
272 if False (default), a new file is written.
273 use_object_data: If True, labels are read from object data; if False (default), from primitive data.
274
275 Raises:
276 ValueError: On invalid arguments (see :func:`labelBoundingBoxes`).
277 HeliosRuntimeError: If ``image_file`` does not exist or the JSON file cannot be written.
278 NotImplementedError: If the native library predates helios-core v1.3.91.
279 """
280 func_name = "writeLabelSegmentationMasks"
281 _check_context(context, func_name)
282 width, height = _check_resolution(resolution, func_name)
283 pixels = _check_pixel_map(pixel_uuids, width, height, func_name)
284 labels, ids = _check_labels(data_labels, class_ids, func_name)
285 _check_filename(json_filename, "json_filename", func_name)
286 _check_filename(image_file, "image_file", func_name)
287 if data_attribute_labels is None:
288 data_attribute_labels = []
289 elif isinstance(data_attribute_labels, str) or not isinstance(data_attribute_labels, (list, tuple)):
290 raise TypeError(f"{func_name}: data_attribute_labels must be a list of strings")
291 if not all(isinstance(label, str) for label in data_attribute_labels):
292 raise TypeError(f"{func_name}: data_attribute_labels must be a list of strings")
293 context_wrapper.annotationWriteLabelSegmentationMasksWrapper(
294 context.getNativePtr(), pixels, width, height, labels, ids, json_filename, image_file,
295 list(data_attribute_labels), bool(append_file), bool(use_object_data))
A bounding box in the normalized form of the YOLO annotation format.
Definition annotation.py:56
None writeLabelBoundingBoxes(Context context, pixel_uuids, int2 resolution, Union[str, List[str]] data_labels, Union[int, List[int]] class_ids, str image_file, str classes_txt_file="classes.txt", str image_path="./", bool use_object_data=False)
Write YOLO bounding boxes and a class file for the labeled objects visible in a pixel-to-primitive ma...
Tuple[int, int] _check_resolution(resolution, str func_name)
Definition annotation.py:77
str _check_filename(value, str name, str func_name)
Tuple[List[str], List[int]] _check_labels(data_labels, class_ids, str func_name)
Dict[int, np.ndarray] labelMasks(Context context, pixel_uuids, int2 resolution, str data_label, bool use_object_data=False)
Build a binary mask for each distinct value of a data label visible in a pixel-to-primitive map.
np.ndarray _check_pixel_map(pixel_uuids, int width, int height, str func_name)
Definition annotation.py:85
List[YOLOBox] labelBoundingBoxes(Context context, pixel_uuids, int2 resolution, Union[str, List[str]] data_labels, Union[int, List[int]] class_ids, bool use_object_data=False)
Compute a bounding box for each labeled object visible in a pixel-to-primitive map.
None writeLabelSegmentationMasks(Context context, pixel_uuids, int2 resolution, Union[str, List[str]] data_labels, Union[int, List[int]] class_ids, str json_filename, str image_file, Optional[List[str]] data_attribute_labels=None, bool append_file=False, bool use_object_data=False)
Write COCO JSON segmentation masks for the labeled objects visible in a pixel-to-primitive map.
None _check_context(context, str func_name)
Definition annotation.py:71