Skip to content

Commit 4122dec

Browse files
authored
Merge pull request #118 from Labelbox/jtso/ontology
Jtso/ontology
2 parents dd4530a + 14f0072 commit 4122dec

File tree

4 files changed

+512
-109
lines changed

4 files changed

+512
-109
lines changed

Dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ FROM python:3.7
22

33
RUN pip install pytest pytest-cases
44

5-
65
WORKDIR /usr/src/labelbox
76
COPY requirements.txt /usr/src/labelbox
87
RUN pip install -r requirements.txt

labelbox/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ class UuidError(LabelboxError):
106106
pass
107107

108108

109+
class InconsistentOntologyException(Exception):
110+
pass
111+
112+
109113
class MALValidationError(LabelboxError):
110114
"""Raised when user input is invalid for MAL imports."""
111115
...

labelbox/schema/ontology.py

Lines changed: 276 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,234 @@
11
import abc
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field
3+
from enum import Enum, auto
4+
import colorsys
35

46
from typing import Any, Callable, Dict, List, Optional, Union
57

8+
from labelbox.schema.project import Project
69
from labelbox.orm import query
710
from labelbox.orm.db_object import DbObject, Updateable, BulkDeletable
811
from labelbox.orm.model import Entity, Field, Relationship
912
from labelbox.utils import snake_case, camel_case
13+
from labelbox.exceptions import InconsistentOntologyException
1014

1115

1216
@dataclass
13-
class OntologyEntity:
14-
required: bool
15-
name: str
17+
class Option:
18+
"""
19+
An option is a possible answer within a Classification object in
20+
a Project's ontology.
1621
22+
To instantiate, only the "value" parameter needs to be passed in.
1723
18-
@dataclass
19-
class Option:
20-
label: str
21-
value: str
24+
Example(s):
25+
option = Option(value = "Option Example")
26+
27+
Attributes:
28+
value: (str)
29+
schema_id: (str)
30+
feature_schema_id: (str)
31+
options: (list)
32+
"""
33+
value: Union[str, int]
34+
schema_id: Optional[str] = None
2235
feature_schema_id: Optional[str] = None
23-
schema_node_id: Optional[str] = None
36+
options: List["Classification"] = field(default_factory=list)
37+
38+
@property
39+
def label(self):
40+
return self.value
2441

2542
@classmethod
26-
def from_json(cls, json_dict):
27-
_dict = convert_keys(json_dict, snake_case)
28-
return cls(**_dict)
43+
def from_dict(cls, dictionary: Dict[str, Any]):
44+
return Option(value=dictionary["value"],
45+
schema_id=dictionary.get("schemaNodeId", []),
46+
feature_schema_id=dictionary.get("featureSchemaId", []),
47+
options=[
48+
Classification.from_dict(o)
49+
for o in dictionary.get("options", [])
50+
])
51+
52+
def asdict(self) -> Dict[str, Any]:
53+
return {
54+
"schemaNodeId": self.schema_id,
55+
"featureSchemaId": self.feature_schema_id,
56+
"label": self.label,
57+
"value": self.value,
58+
"options": [o.asdict() for o in self.options]
59+
}
60+
61+
def add_option(self, option: 'Classification'):
62+
if option.instructions in (o.instructions for o in self.options):
63+
raise InconsistentOntologyException(
64+
f"Duplicate nested classification '{option.instructions}' "
65+
f"for option '{self.label}'")
66+
self.options.append(option)
2967

3068

3169
@dataclass
32-
class Classification(OntologyEntity):
33-
type: str
70+
class Classification:
71+
"""
72+
A classfication to be added to a Project's ontology. The
73+
classification is dependent on the Classification Type.
74+
75+
To instantiate, the "class_type" and "instructions" parameters must
76+
be passed in.
77+
78+
The "options" parameter holds a list of Option objects. This is not
79+
necessary for some Classification types, such as TEXT. To see which
80+
types require options, look at the "_REQUIRES_OPTIONS" class variable.
81+
82+
Example(s):
83+
classification = Classification(
84+
class_type = Classification.Type.TEXT,
85+
instructions = "Classification Example")
86+
87+
classification_two = Classification(
88+
class_type = Classification.Type.RADIO,
89+
instructions = "Second Example")
90+
classification_two.add_option(Option(
91+
value = "Option Example"))
92+
93+
Attributes:
94+
class_type: (Classification.Type)
95+
instructions: (str)
96+
required: (bool)
97+
options: (list)
98+
schema_id: (str)
99+
feature_schema_id: (str)
100+
"""
101+
102+
class Type(Enum):
103+
TEXT = "text"
104+
CHECKLIST = "checklist"
105+
RADIO = "radio"
106+
DROPDOWN = "dropdown"
107+
108+
_REQUIRES_OPTIONS = {Type.CHECKLIST, Type.RADIO, Type.DROPDOWN}
109+
110+
class_type: Type
34111
instructions: str
35-
options: List[Option]
112+
required: bool = False
113+
options: List[Option] = field(default_factory=list)
114+
schema_id: Optional[str] = None
36115
feature_schema_id: Optional[str] = None
37-
schema_node_id: Optional[str] = None
116+
117+
@property
118+
def name(self):
119+
return self.instructions
38120

39121
@classmethod
40-
def from_json(cls, json_dict):
41-
_dict = convert_keys(json_dict, snake_case)
42-
_dict['options'] = [
43-
Option.from_json(option) for option in _dict['options']
44-
]
45-
return cls(**_dict)
122+
def from_dict(cls, dictionary: Dict[str, Any]):
123+
return Classification(
124+
class_type=Classification.Type(dictionary["type"]),
125+
instructions=dictionary["instructions"],
126+
required=dictionary["required"],
127+
options=[Option.from_dict(o) for o in dictionary["options"]],
128+
schema_id=dictionary.get("schemaNodeId", []),
129+
feature_schema_id=dictionary.get("featureSchemaId", []))
130+
131+
def asdict(self) -> Dict[str, Any]:
132+
if self.class_type in Classification._REQUIRES_OPTIONS \
133+
and len(self.options) < 1:
134+
raise InconsistentOntologyException(
135+
f"Classification '{self.instructions}' requires options.")
136+
return {
137+
"type": self.class_type.value,
138+
"instructions": self.instructions,
139+
"name": self.name,
140+
"required": self.required,
141+
"options": [o.asdict() for o in self.options],
142+
"schemaNodeId": self.schema_id,
143+
"featureSchemaId": self.feature_schema_id
144+
}
145+
146+
def add_option(self, option: Option):
147+
if option.value in (o.value for o in self.options):
148+
raise InconsistentOntologyException(
149+
f"Duplicate option '{option.value}' "
150+
f"for classification '{self.name}'.")
151+
self.options.append(option)
46152

47153

48154
@dataclass
49-
class Tool(OntologyEntity):
50-
tool: str
51-
color: str
52-
classifications: List[Classification]
155+
class Tool:
156+
"""
157+
A tool to be added to a Project's ontology. The tool is
158+
dependent on the Tool Type.
159+
160+
To instantiate, the "tool" and "name" parameters must
161+
be passed in.
162+
163+
The "classifications" parameter holds a list of Classification objects.
164+
This can be used to add nested classifications to a tool.
165+
166+
Example(s):
167+
tool = Tool(
168+
tool = Tool.Type.LINE,
169+
name = "Tool example")
170+
classification = Classification(
171+
class_type = Classification.Type.TEXT,
172+
instructions = "Classification Example")
173+
tool.add_classification(classification)
174+
175+
Attributes:
176+
tool: (Tool.Type)
177+
name: (str)
178+
required: (bool)
179+
color: (str)
180+
classifications: (list)
181+
schema_id: (str)
182+
feature_schema_id: (str)
183+
"""
184+
185+
class Type(Enum):
186+
POLYGON = "polygon"
187+
SEGMENTATION = "superpixel"
188+
POINT = "point"
189+
BBOX = "rectangle"
190+
LINE = "line"
191+
NER = "named-entity"
192+
193+
tool: Type
194+
name: str
195+
required: bool = False
196+
color: Optional[str] = None
197+
classifications: List[Classification] = field(default_factory=list)
198+
schema_id: Optional[str] = None
53199
feature_schema_id: Optional[str] = None
54-
schema_node_id: Optional[str] = None
55200

56201
@classmethod
57-
def from_json(cls, json_dict):
58-
_dict = convert_keys(json_dict, snake_case)
59-
_dict['classifications'] = [
60-
Classification.from_json(classification)
61-
for classification in _dict['classifications']
62-
]
63-
return cls(**_dict)
202+
def from_dict(cls, dictionary: Dict[str, Any]):
203+
return Tool(name=dictionary['name'],
204+
schema_id=dictionary.get("schemaNodeId", []),
205+
feature_schema_id=dictionary.get("featureSchemaId", []),
206+
required=dictionary["required"],
207+
tool=Tool.Type(dictionary["tool"]),
208+
classifications=[
209+
Classification.from_dict(c)
210+
for c in dictionary["classifications"]
211+
],
212+
color=dictionary["color"])
213+
214+
def asdict(self) -> Dict[str, Any]:
215+
return {
216+
"tool": self.tool.value,
217+
"name": self.name,
218+
"required": self.required,
219+
"color": self.color,
220+
"classifications": [c.asdict() for c in self.classifications],
221+
"schemaNodeId": self.schema_id,
222+
"featureSchemaId": self.feature_schema_id
223+
}
224+
225+
def add_classification(self, classification: Classification):
226+
if classification.instructions in (
227+
c.instructions for c in self.classifications):
228+
raise InconsistentOntologyException(
229+
f"Duplicate nested classification '{classification.instructions}' "
230+
f"for tool '{self.name}'")
231+
self.classifications.append(classification)
64232

65233

66234
class Ontology(DbObject):
@@ -98,27 +266,89 @@ def tools(self) -> List[Tool]:
98266
"""Get list of tools (AKA objects) in an Ontology."""
99267
if self._tools is None:
100268
self._tools = [
101-
Tool.from_json(tool) for tool in self.normalized['tools']
269+
Tool.from_dict(tool) for tool in self.normalized['tools']
102270
]
103-
return self._tools # type: ignore
271+
return self._tools
104272

105273
def classifications(self) -> List[Classification]:
106274
"""Get list of classifications in an Ontology."""
107275
if self._classifications is None:
108276
self._classifications = [
109-
Classification.from_json(classification)
277+
Classification.from_dict(classification)
110278
for classification in self.normalized['classifications']
111279
]
112-
return self._classifications # type: ignore
280+
return self._classifications
281+
282+
283+
@dataclass
284+
class OntologyBuilder:
285+
"""
286+
A class to help create an ontology for a Project. This should be used
287+
for making Project ontologies from scratch. OntologyBuilder can also
288+
pull from an already existing Project's ontology.
289+
290+
There are no required instantiation arguments.
291+
292+
To create an ontology, use the asdict() method after fully building your
293+
ontology within this class, and inserting it into project.setup() as the
294+
"labeling_frontend_options" parameter.
113295
296+
Example:
297+
builder = OntologyBuilder()
298+
...
299+
frontend = list(client.get_labeling_frontends())[0]
300+
project.setup(frontend, builder.asdict())
114301
115-
def convert_keys(json_dict: Dict[str, Any],
116-
converter: Callable) -> Dict[str, Any]:
117-
if isinstance(json_dict, dict):
302+
attributes:
303+
tools: (list)
304+
classifications: (list)
305+
306+
307+
"""
308+
tools: List[Tool] = field(default_factory=list)
309+
classifications: List[Classification] = field(default_factory=list)
310+
311+
@classmethod
312+
def from_dict(cls, dictionary: Dict[str, Any]):
313+
return OntologyBuilder(
314+
tools=[Tool.from_dict(t) for t in dictionary["tools"]],
315+
classifications=[
316+
Classification.from_dict(c)
317+
for c in dictionary["classifications"]
318+
])
319+
320+
def asdict(self):
321+
self._update_colors()
118322
return {
119-
converter(key): convert_keys(value, converter)
120-
for key, value in json_dict.items()
323+
"tools": [t.asdict() for t in self.tools],
324+
"classifications": [c.asdict() for c in self.classifications]
121325
}
122-
if isinstance(json_dict, list):
123-
return [convert_keys(ele, converter) for ele in json_dict]
124-
return json_dict
326+
327+
def _update_colors(self):
328+
num_tools = len(self.tools)
329+
330+
for index in range(num_tools):
331+
hsv_color = (index * 1 / num_tools, 1, 1)
332+
rgb_color = tuple(
333+
int(255 * x) for x in colorsys.hsv_to_rgb(*hsv_color))
334+
if self.tools[index].color is None:
335+
self.tools[index].color = '#%02x%02x%02x' % rgb_color
336+
337+
@classmethod
338+
def from_project(cls, project: Project):
339+
ontology = project.ontology().normalized
340+
return OntologyBuilder.from_dict(ontology)
341+
342+
def add_tool(self, tool: Tool):
343+
if tool.name in (t.name for t in self.tools):
344+
raise InconsistentOntologyException(
345+
f"Duplicate tool name '{tool.name}'. ")
346+
self.tools.append(tool)
347+
348+
def add_classification(self, classification: Classification):
349+
if classification.instructions in (
350+
c.instructions for c in self.classifications):
351+
raise InconsistentOntologyException(
352+
f"Duplicate classification instructions '{classification.instructions}'. "
353+
)
354+
self.classifications.append(classification)

0 commit comments

Comments
 (0)