|
| 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 | + |
1 | 30 | import json
|
2 | 31 | from dataclasses import dataclass
|
3 | 32 | from enum import Enum
|
|
35 | 64 |
|
36 | 65 |
|
37 | 66 | 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 | + |
38 | 74 | reference_id: str
|
39 | 75 |
|
40 | 76 | @classmethod
|
@@ -62,78 +98,30 @@ def to_json(self) -> str:
|
62 | 98 | return json.dumps(self.to_payload(), allow_nan=False)
|
63 | 99 |
|
64 | 100 |
|
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 |
| - |
135 | 101 | @dataclass # pylint: disable=R0902
|
136 | 102 | 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 | + |
137 | 125 | label: str
|
138 | 126 | x: Union[float, int]
|
139 | 127 | y: Union[float, int]
|
@@ -180,33 +168,39 @@ def to_payload(self) -> dict:
|
180 | 168 |
|
181 | 169 | @dataclass
|
182 | 170 | 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. |
192 | 172 |
|
| 173 | + Attributes: |
| 174 | + x: X coordinate. |
| 175 | + y: Y coordinate. |
| 176 | + """ |
193 | 177 |
|
194 |
| -@dataclass |
195 |
| -class Point3D: |
196 | 178 | x: float
|
197 | 179 | y: float
|
198 |
| - z: float |
199 | 180 |
|
200 | 181 | @classmethod
|
201 | 182 | 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]) |
203 | 184 |
|
204 | 185 | 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} |
206 | 187 |
|
207 | 188 |
|
208 | 189 | @dataclass
|
209 | 190 | 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 | + |
210 | 204 | label: str
|
211 | 205 | vertices: List[Point]
|
212 | 206 | reference_id: str
|
@@ -256,8 +250,45 @@ def to_payload(self) -> dict:
|
256 | 250 | return payload
|
257 | 251 |
|
258 | 252 |
|
| 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 | + |
259 | 275 | @dataclass # pylint: disable=R0902
|
260 | 276 | 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 | + |
261 | 292 | label: str
|
262 | 293 | position: Point3D
|
263 | 294 | dimensions: Point3D
|
@@ -301,8 +332,126 @@ def to_payload(self) -> dict:
|
301 | 332 | return payload
|
302 | 333 |
|
303 | 334 |
|
| 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 | + |
304 | 451 | @dataclass
|
305 | 452 | class CategoryAnnotation(Annotation):
|
| 453 | + """This class is not yet supported: Categorization support coming soon!""" |
| 454 | + |
306 | 455 | label: str
|
307 | 456 | taxonomy_name: str
|
308 | 457 | reference_id: str
|
@@ -333,6 +482,8 @@ def to_payload(self) -> dict:
|
333 | 482 |
|
334 | 483 | @dataclass
|
335 | 484 | class MultiCategoryAnnotation(Annotation):
|
| 485 | + """This class is not yet supported: Categorization support coming soon!""" |
| 486 | + |
336 | 487 | labels: List[str]
|
337 | 488 | taxonomy_name: str
|
338 | 489 | reference_id: str
|
|
0 commit comments