Skip to content

Commit ba1adcc

Browse files
authored
Merge pull request #141 from scaleapi/da-document-annotations
finished documenting annotation.py
2 parents 6c0f0c3 + 24c2a11 commit ba1adcc

File tree

1 file changed

+235
-84
lines changed

1 file changed

+235
-84
lines changed

nucleus/annotation.py

Lines changed: 235 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,32 @@
1+
"""
2+
Adding ground truth to your dataset in Nucleus allows you to visualize annotations,
3+
query dataset items based on the annotations they contain, and evaluate ModelRuns by
4+
comparing predictions to ground truth.
5+
6+
Nucleus supports 2D bounding box, polygon, cuboid, and segmentation annotations.
7+
Cuboid annotations can only be uploaded to a pointcloud DatasetItem.
8+
9+
When uploading an annotation, you need to specify which item you are annotating via
10+
the reference_id you provided when uploading the image or pointcloud.
11+
12+
Ground truth uploads can be made idempotent by specifying an optional annotation_id for
13+
each annotation. This id should be unique within the dataset_item so that
14+
(reference_id, annotation_id) is unique within the dataset.
15+
16+
When uploading a mask annotation, Nucleus expects the mask file to be in PNG format
17+
with each pixel being a 0-255 uint8. Currently, Nucleus only supports uploading masks
18+
from URL.
19+
20+
Nucleus automatically enforces the constraint that each DatasetItem can have at most one
21+
ground truth segmentation mask. As a consequence, if during upload a duplicate mask is
22+
detected for a given image, by default it will be ignored. You can change this behavior
23+
by specifying the optional 'update' flag. Setting update = true will replace the
24+
existing segmentation with the new mask specified in the request body.
25+
26+
For ingesting large datasets, see the Guide for Large Ingestions (TODOC: add link here)
27+
"""
28+
29+
130
import json
231
from dataclasses import dataclass
332
from enum import Enum
@@ -35,6 +64,13 @@
3564

3665

3766
class Annotation:
67+
"""Simply a base class, not to be used directly
68+
69+
Attributes:
70+
reference_id: The reference ID of the dataset item you wish to associate this
71+
annotation with
72+
"""
73+
3874
reference_id: str
3975

4076
@classmethod
@@ -62,78 +98,30 @@ def to_json(self) -> str:
6298
return json.dumps(self.to_payload(), allow_nan=False)
6399

64100

65-
@dataclass
66-
class Segment:
67-
label: str
68-
index: int
69-
metadata: Optional[dict] = None
70-
71-
@classmethod
72-
def from_json(cls, payload: dict):
73-
return cls(
74-
label=payload.get(LABEL_KEY, ""),
75-
index=payload.get(INDEX_KEY, None),
76-
metadata=payload.get(METADATA_KEY, None),
77-
)
78-
79-
def to_payload(self) -> dict:
80-
payload = {
81-
LABEL_KEY: self.label,
82-
INDEX_KEY: self.index,
83-
}
84-
if self.metadata is not None:
85-
payload[METADATA_KEY] = self.metadata
86-
return payload
87-
88-
89-
@dataclass
90-
class SegmentationAnnotation(Annotation):
91-
mask_url: str
92-
annotations: List[Segment]
93-
reference_id: str
94-
annotation_id: Optional[str] = None
95-
96-
def __post_init__(self):
97-
if not self.mask_url:
98-
raise Exception("You must specify a mask_url.")
99-
100-
@classmethod
101-
def from_json(cls, payload: dict):
102-
if MASK_URL_KEY not in payload:
103-
raise ValueError(f"Missing {MASK_URL_KEY} in json")
104-
return cls(
105-
mask_url=payload[MASK_URL_KEY],
106-
annotations=[
107-
Segment.from_json(ann)
108-
for ann in payload.get(ANNOTATIONS_KEY, [])
109-
],
110-
reference_id=payload[REFERENCE_ID_KEY],
111-
annotation_id=payload.get(ANNOTATION_ID_KEY, None),
112-
)
113-
114-
def to_payload(self) -> dict:
115-
payload = {
116-
TYPE_KEY: MASK_TYPE,
117-
MASK_URL_KEY: self.mask_url,
118-
ANNOTATIONS_KEY: [ann.to_payload() for ann in self.annotations],
119-
ANNOTATION_ID_KEY: self.annotation_id,
120-
}
121-
122-
payload[REFERENCE_ID_KEY] = self.reference_id
123-
124-
return payload
125-
126-
127-
class AnnotationTypes(Enum):
128-
BOX = BOX_TYPE
129-
POLYGON = POLYGON_TYPE
130-
CUBOID = CUBOID_TYPE
131-
CATEGORY = CATEGORY_TYPE
132-
MULTICATEGORY = MULTICATEGORY_TYPE
133-
134-
135101
@dataclass # pylint: disable=R0902
136102
class BoxAnnotation(Annotation): # pylint: disable=R0902
103+
"""A bounding box annotation.
104+
105+
Attributes:
106+
x: The distance, in pixels, between the left border of the bounding box and the
107+
left border of the image.
108+
y: The distance, in pixels, between the top border of the bounding box and the
109+
top border of the image.
110+
width: The width in pixels of the annotation.
111+
height: The height in pixels of the annotation.
112+
reference_id: The reference ID of the image you wish to apply this annotation to.
113+
annotation_id: The annotation ID that uniquely identifies this annotation within
114+
its target dataset item. Upon ingest, a matching annotation id will be
115+
ignored by default, and updated if update=True for dataset.annotate.
116+
If no annotation ID is passed, one will be automatically generated using the
117+
label, x, y, width, and height, so that you can make inserts idempotently
118+
and identical boxes will be ignored.
119+
label: The label for this annotation (e.g. car, pedestrian, bicycle)
120+
metadata: Arbitrary key/value dictionary of info to attach to this annotation.
121+
Strings, floats and ints are supported best by querying and insights
122+
features within Nucleus. For more details see TODOC: (Insert link to metadata guide).
123+
"""
124+
137125
label: str
138126
x: Union[float, int]
139127
y: Union[float, int]
@@ -180,33 +168,39 @@ def to_payload(self) -> dict:
180168

181169
@dataclass
182170
class Point:
183-
x: float
184-
y: float
185-
186-
@classmethod
187-
def from_json(cls, payload: Dict[str, float]):
188-
return cls(payload[X_KEY], payload[Y_KEY])
189-
190-
def to_payload(self) -> dict:
191-
return {X_KEY: self.x, Y_KEY: self.y}
171+
"""A 2D point.
192172
173+
Attributes:
174+
x: X coordinate.
175+
y: Y coordinate.
176+
"""
193177

194-
@dataclass
195-
class Point3D:
196178
x: float
197179
y: float
198-
z: float
199180

200181
@classmethod
201182
def from_json(cls, payload: Dict[str, float]):
202-
return cls(payload[X_KEY], payload[Y_KEY], payload[Z_KEY])
183+
return cls(payload[X_KEY], payload[Y_KEY])
203184

204185
def to_payload(self) -> dict:
205-
return {X_KEY: self.x, Y_KEY: self.y, Z_KEY: self.z}
186+
return {X_KEY: self.x, Y_KEY: self.y}
206187

207188

208189
@dataclass
209190
class PolygonAnnotation(Annotation):
191+
"""A polygon annotation consisting of an ordered list of 2D points.
192+
193+
Attributes:
194+
label: The label for this annotation (e.g. car, pedestrian, bicycle).
195+
vertices: The list of points making up the polygon.
196+
annotation_id: The annotation ID that uniquely identifies this annotation within
197+
its target dataset item. Upon ingest, a matching annotation id will be
198+
ignored by default, and updated if update=True for dataset.annotate.
199+
metadata: Arbitrary key/value dictionary of info to attach to this annotation.
200+
Strings, floats and ints are supported best by querying and insights
201+
features within Nucleus. For more details see TODOC: (Insert link to metadata guide).
202+
"""
203+
210204
label: str
211205
vertices: List[Point]
212206
reference_id: str
@@ -256,8 +250,45 @@ def to_payload(self) -> dict:
256250
return payload
257251

258252

253+
@dataclass
254+
class Point3D:
255+
"""A point in 3D space.
256+
257+
Attributes:
258+
x: The x coordinate of the point.
259+
y: The y coordinate of the point.
260+
z: The z coordinate of the point.
261+
"""
262+
263+
x: float
264+
y: float
265+
z: float
266+
267+
@classmethod
268+
def from_json(cls, payload: Dict[str, float]):
269+
return cls(payload[X_KEY], payload[Y_KEY], payload[Z_KEY])
270+
271+
def to_payload(self) -> dict:
272+
return {X_KEY: self.x, Y_KEY: self.y, Z_KEY: self.z}
273+
274+
259275
@dataclass # pylint: disable=R0902
260276
class CuboidAnnotation(Annotation): # pylint: disable=R0902
277+
"""A 3D Cuboid annotation.
278+
279+
Attributes:
280+
label: The label for this annotation (e.g. car, pedestrian, bicycle)
281+
position: The point at the center of the cuboid
282+
dimensions: The length (x), width (y), and height (z) of the cuboid
283+
yaw: The rotation, in radians, about the Z axis of the cuboid
284+
annotation_id: The annotation ID that uniquely identifies this annotation within
285+
its target dataset item. Upon ingest, a matching annotation id will be
286+
ignored by default, and updated if update=True for dataset.annotate.
287+
metadata: Arbitrary key/value dictionary of info to attach to this annotation.
288+
Strings, floats and ints are supported best by querying and insights
289+
features within Nucleus. For more details see TODOC: (Insert link to metadata guide).
290+
"""
291+
261292
label: str
262293
position: Point3D
263294
dimensions: Point3D
@@ -301,8 +332,126 @@ def to_payload(self) -> dict:
301332
return payload
302333

303334

335+
@dataclass
336+
class Segment:
337+
"""Segment represents either a class or an instance depending on the task type.
338+
339+
For semantic segmentation, this object should store the mapping between a single
340+
class index and the string label.
341+
342+
For instance segmentation, you can use this class to store the label of a single
343+
instance, whose extent in the image is represented by the value of 'index'.
344+
345+
In either case, additional metadata can be attached to the segment.
346+
347+
Attributes:
348+
label: The label name of the class for the class or instance represented by index in the associated mask.
349+
index: The integer pixel value in the mask this mapping refers to.
350+
metadata: Arbitrary key/value dictionary of info to attach to this segment.
351+
Strings, floats and ints are supported best by querying and insights
352+
features within Nucleus. For more details see TODOC: (Insert link to metadata guide).
353+
"""
354+
355+
label: str
356+
index: int
357+
metadata: Optional[dict] = None
358+
359+
@classmethod
360+
def from_json(cls, payload: dict):
361+
return cls(
362+
label=payload.get(LABEL_KEY, ""),
363+
index=payload.get(INDEX_KEY, None),
364+
metadata=payload.get(METADATA_KEY, None),
365+
)
366+
367+
def to_payload(self) -> dict:
368+
payload = {
369+
LABEL_KEY: self.label,
370+
INDEX_KEY: self.index,
371+
}
372+
if self.metadata is not None:
373+
payload[METADATA_KEY] = self.metadata
374+
return payload
375+
376+
377+
@dataclass
378+
class SegmentationAnnotation(Annotation):
379+
"""A segmentation mask on 2D image.
380+
381+
Attributes:
382+
mask_url: A URL pointing to the segmentation prediction mask which is
383+
accessible to Scale. The mask is an HxW int8 array saved in PNG format,
384+
with each pixel value ranging from [0, N), where N is the number of possible
385+
classes (for semantic segmentation) or instances (for instance
386+
segmentation). The height and width of the mask must be the same as the
387+
original image. One example for semantic segmentation: the mask is 0 for
388+
pixels where there is background, 1 where there is a car, and 2 where there
389+
is a pedestrian. Another example for instance segmentation: the mask is 0
390+
for one car, 1 for another car, 2 for a motorcycle and 3 for another
391+
motorcycle. The class name for each value in the mask is stored in the list
392+
of Segment objects passed for "annotations"
393+
annotations: The list of mappings between the integer values contained in
394+
mask_url and string class labels. In the semantic segmentation example above
395+
these would map that 0 to background, 1 to car and 2 to pedestrian. In the
396+
instance segmentation example above, 0 and 1 would both be mapped to car,
397+
2 and 3 would both be mapped to motorcycle
398+
annotation_id: For segmentation annotations, this value is ignored because
399+
there can only be one segmentation annotation per dataset item. Therefore
400+
regardless of annotation ID, if there is an existing segmentation on a
401+
dataset item, it will be ignored unless update=True is passed to
402+
dataset.annotate, in which case it will be updated. Storing a custom ID here
403+
may be useful in order to tie this annotation to an external database, and
404+
its value will be returned for any export.
405+
"""
406+
407+
mask_url: str
408+
annotations: List[Segment]
409+
reference_id: str
410+
annotation_id: Optional[str] = None
411+
412+
def __post_init__(self):
413+
if not self.mask_url:
414+
raise Exception("You must specify a mask_url.")
415+
416+
@classmethod
417+
def from_json(cls, payload: dict):
418+
if MASK_URL_KEY not in payload:
419+
raise ValueError(f"Missing {MASK_URL_KEY} in json")
420+
return cls(
421+
mask_url=payload[MASK_URL_KEY],
422+
annotations=[
423+
Segment.from_json(ann)
424+
for ann in payload.get(ANNOTATIONS_KEY, [])
425+
],
426+
reference_id=payload[REFERENCE_ID_KEY],
427+
annotation_id=payload.get(ANNOTATION_ID_KEY, None),
428+
)
429+
430+
def to_payload(self) -> dict:
431+
payload = {
432+
TYPE_KEY: MASK_TYPE,
433+
MASK_URL_KEY: self.mask_url,
434+
ANNOTATIONS_KEY: [ann.to_payload() for ann in self.annotations],
435+
ANNOTATION_ID_KEY: self.annotation_id,
436+
}
437+
438+
payload[REFERENCE_ID_KEY] = self.reference_id
439+
440+
return payload
441+
442+
443+
class AnnotationTypes(Enum):
444+
BOX = BOX_TYPE
445+
POLYGON = POLYGON_TYPE
446+
CUBOID = CUBOID_TYPE
447+
CATEGORY = CATEGORY_TYPE
448+
MULTICATEGORY = MULTICATEGORY_TYPE
449+
450+
304451
@dataclass
305452
class CategoryAnnotation(Annotation):
453+
"""This class is not yet supported: Categorization support coming soon!"""
454+
306455
label: str
307456
taxonomy_name: str
308457
reference_id: str
@@ -333,6 +482,8 @@ def to_payload(self) -> dict:
333482

334483
@dataclass
335484
class MultiCategoryAnnotation(Annotation):
485+
"""This class is not yet supported: Categorization support coming soon!"""
486+
336487
labels: List[str]
337488
taxonomy_name: str
338489
reference_id: str

0 commit comments

Comments
 (0)