Skip to content

Commit b329768

Browse files
authored
Add basic polygon metrics to nucleus repo (#185)
1 parent 90b4f8e commit b329768

File tree

15 files changed

+926
-6
lines changed

15 files changed

+926
-6
lines changed

.circleci/config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ orbs:
77
jobs:
88
build_test:
99
docker:
10-
- image: python:3.6-buster
10+
- image: python:3.7-buster
1111
resource_class: small
1212
parallelism: 6
1313
steps:
@@ -23,7 +23,7 @@ jobs:
2323
- run:
2424
name: Black Formatting Check # Only validation, without re-formatting
2525
command: |
26-
poetry run black --check -t py36 .
26+
poetry run black --check -t py37 .
2727
- run:
2828
name: Flake8 Lint Check # Uses setup.cfg for configuration
2929
command: |

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ All notable changes to the [Nucleus Python Client](https://github.com/scaleapi/n
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7-
## [0.4.5](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.4.4) - 2021-01-07
7+
## [0.5.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.5.0) - 2021-01-10
8+
9+
### Added
10+
- `nucleus.metrics` module for computing metrics between Nucleus `Annotation` and `Prediction` objects.
11+
12+
## [0.4.5](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.4.5) - 2021-01-07
813

914
### Added
1015
- `Dataset.scenes` property that fetches the Scale-generated ID, reference ID, type, and metadata of all scenes in the Dataset.

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Sections
3131
:maxdepth: 4
3232

3333
api/nucleus/index
34+
api/nucleus/metrics/index
3435
api/nucleus/modelci/index
3536

3637

nucleus/annotation.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import json
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field
33
from enum import Enum
44
from typing import Dict, List, Optional, Sequence, Union
55
from urllib.parse import urlparse
@@ -595,6 +595,56 @@ def to_payload(self) -> dict:
595595
return payload
596596

597597

598+
@dataclass
599+
class AnnotationList:
600+
"""Wrapper class separating a list of annotations by type."""
601+
602+
box_annotations: List[BoxAnnotation] = field(default_factory=list)
603+
polygon_annotations: List[PolygonAnnotation] = field(default_factory=list)
604+
cuboid_annotations: List[CuboidAnnotation] = field(default_factory=list)
605+
category_annotations: List[CategoryAnnotation] = field(
606+
default_factory=list
607+
)
608+
multi_category_annotations: List[MultiCategoryAnnotation] = field(
609+
default_factory=list
610+
)
611+
segmentation_annotations: List[SegmentationAnnotation] = field(
612+
default_factory=list
613+
)
614+
615+
def add_annotations(self, annotations: List[Annotation]):
616+
for annotation in annotations:
617+
assert isinstance(
618+
annotation, Annotation
619+
), "Expected annotation to be of type 'Annotation"
620+
621+
if isinstance(annotation, BoxAnnotation):
622+
self.box_annotations.append(annotation)
623+
elif isinstance(annotation, PolygonAnnotation):
624+
self.polygon_annotations.append(annotation)
625+
elif isinstance(annotation, CuboidAnnotation):
626+
self.cuboid_annotations.append(annotation)
627+
elif isinstance(annotation, CategoryAnnotation):
628+
self.category_annotations.append(annotation)
629+
elif isinstance(annotation, MultiCategoryAnnotation):
630+
self.multi_category_annotations.append(annotation)
631+
else:
632+
assert isinstance(
633+
annotation, SegmentationAnnotation
634+
), f"Unexpected annotation type: {type(annotation)}"
635+
self.segmentation_annotations.append(annotation)
636+
637+
def __len__(self):
638+
return (
639+
len(self.box_annotations)
640+
+ len(self.polygon_annotations)
641+
+ len(self.cuboid_annotations)
642+
+ len(self.category_annotations)
643+
+ len(self.multi_category_annotations)
644+
+ len(self.segmentation_annotations)
645+
)
646+
647+
598648
def is_local_path(path: str) -> bool:
599649
return urlparse(path).scheme not in {"https", "http", "s3", "gs"}
600650

nucleus/metrics/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .base import Metric, MetricResult
2+
from .polygon_metrics import (
3+
PolygonIOU,
4+
PolygonMetric,
5+
PolygonPrecision,
6+
PolygonRecall,
7+
)

nucleus/metrics/base.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import sys
2+
from abc import ABC, abstractmethod
3+
from dataclasses import dataclass
4+
from typing import Iterable
5+
6+
from nucleus.annotation import AnnotationList
7+
from nucleus.prediction import PredictionList
8+
9+
10+
@dataclass
11+
class MetricResult:
12+
"""A Metric Result contains the value of an evaluation, as well as its weight.
13+
The weight is useful when aggregating metrics where each dataset item may hold a
14+
different relative weight. For example, when calculating precision over a dataset,
15+
the denominator of the precision is the number of annotations, and therefore the weight
16+
can be set as the number of annotations.
17+
18+
Attributes:
19+
value (float): The value of the evaluation result
20+
weight (float): The weight of the evaluation result.
21+
"""
22+
23+
value: float
24+
weight: float = 1.0
25+
26+
@staticmethod
27+
def aggregate(results: Iterable["MetricResult"]) -> "MetricResult":
28+
"""Aggregates results using a weighted average."""
29+
results = list(filter(lambda x: x.weight != 0, results))
30+
total_weight = sum([result.weight for result in results])
31+
total_value = sum([result.value * result.weight for result in results])
32+
value = total_value / max(total_weight, sys.float_info.epsilon)
33+
return MetricResult(value, total_weight)
34+
35+
36+
class Metric(ABC):
37+
"""Abstract class for defining a metric, which takes a list of annotations
38+
and predictions and returns a scalar.
39+
40+
To create a new concrete Metric, override the `__call__` function
41+
with logic to define a metric between annotations and predictions. ::
42+
43+
from nucleus import BoxAnnotation, CuboidPrediction, Point3D
44+
from nucleus.annotation import AnnotationList
45+
from nucleus.prediction import PredictionList
46+
from nucleus.metrics import Metric, MetricResult
47+
from nucleus.metrics.polygon_utils import BoxOrPolygonAnnotation, BoxOrPolygonPrediction
48+
49+
class MyMetric(Metric):
50+
def __call__(
51+
self, annotations: AnnotationList, predictions: PredictionList
52+
) -> MetricResult:
53+
value = (len(annotations) - len(predictions)) ** 2
54+
weight = len(annotations)
55+
return MetricResult(value, weight)
56+
57+
box = BoxAnnotation(
58+
label="car",
59+
x=0,
60+
y=0,
61+
width=10,
62+
height=10,
63+
reference_id="image_1",
64+
annotation_id="image_1_car_box_1",
65+
metadata={"vehicle_color": "red"}
66+
)
67+
68+
cuboid = CuboidPrediction(
69+
label="car",
70+
position=Point3D(100, 100, 10),
71+
dimensions=Point3D(5, 10, 5),
72+
yaw=0,
73+
reference_id="pointcloud_1",
74+
confidence=0.8,
75+
annotation_id="pointcloud_1_car_cuboid_1",
76+
metadata={"vehicle_color": "green"}
77+
)
78+
79+
metric = MyMetric()
80+
annotations = AnnotationList(box_annotations=[box])
81+
predictions = PredictionList(cuboid_predictions=[cuboid])
82+
metric(annotations, predictions)
83+
"""
84+
85+
@abstractmethod
86+
def __call__(
87+
self, annotations: AnnotationList, predictions: PredictionList
88+
) -> MetricResult:
89+
"""A metric must override this method and return a metric result, given annotations and predictions."""

nucleus/metrics/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class PolygonAnnotationTypeError(Exception):
2+
def __init__(
3+
self,
4+
message="Annotation was expected to be of type 'BoxAnnotation' or 'PolygonAnnotation'.",
5+
):
6+
self.message = message
7+
super().__init__(self.message)

nucleus/metrics/filters.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from typing import List
2+
3+
from nucleus.prediction import PredictionList
4+
5+
from .polygon_utils import BoxOrPolygonAnnotation, polygon_annotation_to_shape
6+
7+
8+
def polygon_area_filter(
9+
polygons: List[BoxOrPolygonAnnotation], min_area: float, max_area: float
10+
) -> List[BoxOrPolygonAnnotation]:
11+
filter_fn = (
12+
lambda polygon: min_area
13+
<= polygon_annotation_to_shape(polygon)
14+
<= max_area
15+
)
16+
return list(filter(filter_fn, polygons))
17+
18+
19+
def confidence_filter(
20+
predictions: PredictionList, min_confidence: float
21+
) -> PredictionList:
22+
predictions_copy = PredictionList()
23+
filter_fn = (
24+
lambda prediction: not hasattr(prediction, "confidence")
25+
or prediction.confidence >= min_confidence
26+
)
27+
for attr in predictions.__dict__:
28+
predictions_copy.__dict__[attr] = list(
29+
filter(filter_fn, predictions.__dict__[attr])
30+
)
31+
return predictions_copy

0 commit comments

Comments
 (0)