Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/labelbox/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Labelbox Python SDK Documentation
issue-position
label
label-score
label-version
labeling-frontend
labeling-frontend-options
labeling-parameter-override
Expand Down
6 changes: 6 additions & 0 deletions docs/labelbox/label-version.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Label Version
===============================================================================================

.. automodule:: labelbox.schema.label_version
:members: LabelVersion, LabelVersionStage, LabelVersionAction
:show-inheritance:
5 changes: 5 additions & 0 deletions libs/labelbox/src/labelbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@
from labelbox.schema.organization import Organization
from labelbox.schema.project import Project
from labelbox.schema.project_model_config import ProjectModelConfig
from labelbox.schema.label_version import (
LabelVersion,
LabelVersionAction,
LabelVersionStage,
)
from labelbox.schema.performance import (
PerformanceCursorRows,
PerformanceDeletedLabels,
Expand Down
27 changes: 27 additions & 0 deletions libs/labelbox/src/labelbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from labelbox.schema.foundry.foundry_client import FoundryClient
from labelbox.schema.iam_integration import IAMIntegration
from labelbox.schema.identifiables import DataRowIds, GlobalKeys
from labelbox.schema import label_version as _label_version
from labelbox.schema.label_score import LabelScore
from labelbox.schema.labeling_frontend import LabelingFrontend
from labelbox.schema import performance as _performance
Expand Down Expand Up @@ -2786,6 +2787,32 @@ def get_sign_in_events(
page_size=page_size,
)

def get_label_version(
self, version_id: str, include_annotations: bool = True
) -> Optional[_label_version.LabelVersion]:
"""Returns one recorded version of a label.

List a project's versions with :meth:`Project.label_versions` or one
label's with :meth:`Label.versions`.

Args:
version_id (str): The version's ID (``LabelVersion.id``).
include_annotations (bool): Also read the annotations at that
moment (``label`` and ``label_json``). Defaults to True.

Returns:
Optional[LabelVersion]: ``None`` if it does not exist or you may
not read its label.

Example:
>>> version = client.get_label_version("cm2v...")
>>> print(version.action, version.created_by_id, version.created_at)
>>> annotations = version.label_json
"""
return _label_version.get_label_version(
self, version_id, include_annotations=include_annotations
)

def get_task_by_id(self, task_id: str) -> Union[Task, DataUpsertTask]:
"""
Fetches a task by ID.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,11 @@ def from_common(
else:
binary = np.all(mask.mask.value == mask.color, axis=-1)
im_bytes = BytesIO()
Image.fromarray(binary, "L").save(im_bytes, format="PNG")
# Pillow 13 refuses a bool array with mode "L"; uint8 0/1 gives
# the same L-mode PNG that older Pillow produced from it.
Image.fromarray(binary.astype(np.uint8)).save(
im_bytes, format="PNG"
)
lbv1_mask = _PNGMask(
png=base64.b64encode(im_bytes.getvalue()).decode("utf-8")
)
Expand Down
27 changes: 27 additions & 0 deletions libs/labelbox/src/labelbox/schema/label.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import TYPE_CHECKING

from labelbox.orm import query
from labelbox.pagination import PaginatedCollection
from labelbox.schema import label_version as _label_version
from labelbox.orm.db_object import DbObject, Updateable, BulkDeletable
from labelbox.orm.model import Entity, Field, Relationship

Expand Down Expand Up @@ -44,6 +46,31 @@ def __init__(self, *args, **kwargs):
reviews = Relationship.ToMany("Review", False)
created_by = Relationship.ToOne("User", False, "created_by")

def versions(self, include_annotations: bool = True) -> PaginatedCollection:
"""Returns this label's recorded versions, oldest first.

See :meth:`labelbox.schema.project.Project.label_versions` for what a
version is and who may read it.

Args:
include_annotations (bool): Also read each version's annotations
(``label`` and ``label_json``). Defaults to True.

Returns:
PaginatedCollection of LabelVersion.

Example:
>>> for version in label.versions():
>>> print(version.created_at, version.action,
>>> version.created_by_id, version.label_json)
"""
return _label_version.get_label_versions(
self.client,
self.project().uid,
label=self.uid,
include_annotations=include_annotations,
)

@staticmethod
def bulk_delete(labels) -> None:
"""Deletes all the given Labels.
Expand Down
221 changes: 221 additions & 0 deletions libs/labelbox/src/labelbox/schema/label_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"""Label version history.

Labelbox records a version of a label at each hand-off: when the label is
submitted or saved from the editor, approved or rejected in a review step,
submitted from a rework step, or moved into a rework step. Each version holds
who made the hand-off, when, in which workflow step, and the label's
annotations at that moment. Autosaves and drafts record no versions, and
versions are never changed or deleted.

The methods on :class:`labelbox.Client`, :class:`labelbox.schema.project.Project`
and :class:`labelbox.schema.label.Label` that return the classes below are thin
wrappers over the functions at the bottom of this module.
"""

import json
from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional, Union

from labelbox.pagination import PaginatedCollection
from labelbox.utils import _CamelCaseMixin

if TYPE_CHECKING:
from labelbox import Client

# The API returns at most 100 versions per page.
_MAX_PAGE_SIZE = 100

DateTimeLike = Union[datetime, str]
"""A moment: a ``datetime`` (naive ones are read as UTC) or an ISO 8601 string."""


class LabelVersionAction(str, Enum):
"""The hand-off a label version records."""

#: The label was created from the editor: a labeler's submit.
SUBMITTED = "SUBMITTED"
#: An existing label was saved from the editor: a reviewer's edit in a
#: review step, a labeler's edit in a rework step, or a later save.
SAVED = "SAVED"
#: A reviewer approved the label in a review step.
APPROVED = "APPROVED"
#: A reviewer rejected the label in a review step.
REJECTED = "REJECTED"
#: The data row was submitted from a rework step.
REWORK_SUBMITTED = "REWORK_SUBMITTED"
#: The data row was moved into a rework step by hand.
SENT_TO_REWORK = "SENT_TO_REWORK"


class LabelVersionStage(_CamelCaseMixin):
"""The workflow step a label version was recorded in, as it was then.

Attributes:
task_queue_id (str): The step's task queue ID.
name (Optional[str]): The step's name when the version was recorded.
type (Optional[str]): The step's type, for example
``"MANUAL_REVIEW_QUEUE"`` or ``"MANUAL_REWORK_QUEUE"``.
"""

task_queue_id: str
name: Optional[str] = None
type: Optional[str] = None


class LabelVersion(_CamelCaseMixin):
"""One recorded version of a label.

Attributes:
id (str): The version's ID. It never changes.
label_id (str)
data_row_id (str)
project_id (str)
action (str): The hand-off (see :class:`LabelVersionAction`).
Unknown values are kept as strings.
created_at (datetime): When the version was recorded.
created_by_id (str): The user who made the hand-off: the labeler for
a submit, the reviewer for a review save or decision.
stage (Optional[LabelVersionStage]): The workflow step the data row
was in. ``None`` when it was in none (Done).
annotation_count (int): Number of annotations (features) in the
version.
label (Optional[str]): The annotations at that moment, as stringified
JSON in the same shape as :attr:`labelbox.schema.label.Label.label`.
Feature schema names are the ones in effect then. ``None`` when
read with ``include_annotations=False``.
label_json (Optional[dict]): ``label`` parsed.
"""

id: str
label_id: str
data_row_id: str
project_id: str
action: Union[LabelVersionAction, str]
created_at: datetime
created_by_id: str
stage: Optional[LabelVersionStage] = None
annotation_count: int
label: Optional[str] = None
label_json: Optional[Dict[str, Any]] = None


_FIELDS = """
id labelId dataRowId projectId action createdAt createdById
stage { taskQueueId name type }
annotationCount
"""
_ANNOTATION_FIELDS = "label labelJson"


def _fields(include_annotations: bool) -> str:
return _FIELDS + (_ANNOTATION_FIELDS if include_annotations else "")


def _to_id(name: str, value: Any) -> Optional[str]:
if value is None:
return None
uid = getattr(value, "uid", value)
if not isinstance(uid, str) or not uid:
raise ValueError(f"{name} must be an ID or an SDK object with one")
return uid


def _to_datetime(
name: str, value: Optional[DateTimeLike]
) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError(
f"{name} must be a datetime or an ISO 8601 string"
) from error
if not isinstance(value, datetime):
raise ValueError(f"{name} must be a datetime or an ISO 8601 string")
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)


def _iso(value: Optional[datetime]) -> Optional[str]:
return (
value.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if value
else None
)


def _to_version(result: Dict[str, Any]) -> LabelVersion:
label_json = result.get("labelJson")
if isinstance(label_json, str):
label_json = json.loads(label_json)
action = result.get("action")
if action in LabelVersionAction._value2member_map_:
action = LabelVersionAction(action)
return LabelVersion.model_validate(
{**result, "labelJson": label_json, "action": action}
)


def get_label_versions(
client: "Client",
project_id: str,
label: Any = None,
data_row: Any = None,
created_by: Any = None,
created_after: Optional[DateTimeLike] = None,
created_before: Optional[DateTimeLike] = None,
include_annotations: bool = True,
page_size: int = _MAX_PAGE_SIZE,
) -> PaginatedCollection:
if not isinstance(page_size, int) or not 1 <= page_size <= _MAX_PAGE_SIZE:
raise ValueError(f"page_size must be between 1 and {_MAX_PAGE_SIZE}")
after = _to_datetime("created_after", created_after)
before = _to_datetime("created_before", created_before)
if after and before and after >= before:
raise ValueError("created_after must be before created_before")
where = {
key: value
for key, value in {
"projectId": _to_id("project", project_id),
"labelId": _to_id("label", label),
"dataRowId": _to_id("data_row", data_row),
"createdById": _to_id("created_by", created_by),
"createdAfter": _iso(after),
"createdBefore": _iso(before),
}.items()
if value is not None
}
query = """query GetLabelVersionsPyApi(
$where: LabelVersionsWhereInput!, $first: Int, $from: String
) {
labelVersions(where: $where, first: $first, after: $from) {
nodes { %s }
nextCursor
}
}""" % _fields(include_annotations)
# The input object is a nested dict: PaginatedCollection only types
# scalars.
params: Dict[str, Any] = {"where": where, "first": page_size}
return PaginatedCollection(
client=client,
query=query,
params=params,
dereferencing=["labelVersions", "nodes"],
obj_class=lambda _, result: _to_version(result),
cursor_path=["labelVersions", "nextCursor"],
)


def get_label_version(
client: "Client", version_id: str, include_annotations: bool = True
) -> Optional[LabelVersion]:
query = """query GetLabelVersionPyApi($id: ID!) {
labelVersion(where: { id: $id }) { %s }
}""" % _fields(include_annotations)
result = client.execute(query, {"id": _to_id("version_id", version_id)})
version = (result or {}).get("labelVersion")
return _to_version(version) if version else None
Loading
Loading