Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2665,6 +2665,17 @@ annotation_id = client.create_annotation(
project="YOUR_PROJECT_SLUG", type="bbox", value="cat", title="Cat", color="#FF0000", attributes=attributes)
```

Create a new segmentation annotation that allows disjoint regions.

`max_area_count` is the maximum number of separate regions a single segmentation annotation may
consist of, between 1 and 1000. It only applies to segmentation classes and defaults to `1`, which
disallows disjoint regions. Set `None` to allow any number of regions.

```python
annotation_id = client.create_annotation(
project="YOUR_PROJECT_SLUG", type="segmentation", value="cat", title="Cat", max_area_count=None)
```

Create a new classification annotation.

```python
Expand Down Expand Up @@ -2831,6 +2842,16 @@ annotation_id = client.update_annotation(
annotation_id="YOUR_ANNOTATION_ID", value="cat2", title="Cat2", color="#FF0000", attributes=attributes)
```

Update the maximum number of regions of a segmentation annotation.

`max_area_count` accepts a value between 1 and 1000 and is left unchanged when omitted. Set `None`
to allow any number of regions.

```python
annotation_id = client.update_annotation(
annotation_id="YOUR_ANNOTATION_ID", max_area_count=None)
```

Update a classification annotation.

```python
Expand Down
57 changes: 57 additions & 0 deletions fastlabel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@
)


class _Unset:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このようなセンチネルはsdkとして公開する都合上、念のためシングルトンにしておいた方がいいですね。copy.deepcopy や pickle を通ると is 判定が壊れて _Unset が payload に載ってしまうので。

シングルトンとしては普通にこんな感じ

  _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

シングルトンにしました。deepcopy / pickle を往復しても is 判定が保たれ、payload にマーカーが載らないことをテストで固定しています。

"""Marker for arguments the caller did not pass.

Most optional arguments here have two states and None covers the second
one: the field is either sent or left out. maxAreaCount has three, because
the API represents "unlimited" as null rather than as 0, so null is itself
a value that has to reach the API -- left out, sent as null, sent as a
number. None is taken up by that null and cannot also mean "the caller
said nothing", so this marker carries that state instead and the argument
is read by identity rather than by truthiness.

A single shared instance, so that the identity checks that read this
marker still hold after it has been copied or serialised on its way
through caller code.
"""

_instance: Optional["_Unset"] = None

def __new__(cls) -> "_Unset":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __repr__(self) -> str:
return "UNSET"


# Typed as Any so that the marker stays out of the public signatures that use it
# as their default. Callers only ever pass an int or None.
_UNSET: Any = _Unset()


class Client:
api = None

Expand Down Expand Up @@ -4297,6 +4329,7 @@ def create_annotation(
color: str = None,
order: int = None,
attributes: list = [],
max_area_count: Optional[int] = _UNSET,
) -> str:
"""
Create an annotation.
Expand All @@ -4308,6 +4341,11 @@ def create_annotation(
title is a display name of value (Required).
color is hex color code like #ffffff (Optional).
attributes is a list of attribute (Optional).
max_area_count is the maximum number of separate regions a single
segmentation annotation may consist of, between 1 and 1000 (Optional).
It only applies to segmentation classes. When omitted the API applies
its default of 1, which disallows disjoint regions. Set None to allow
any number of regions.
"""
endpoint = "annotations"
payload = {
Expand All @@ -4322,6 +4360,13 @@ def create_annotation(
payload["order"] = order
if attributes:
payload["attributes"] = attributes
# maxAreaCount represents "unlimited" as null rather than as 0, so
# passing None is itself a meaningful call and None cannot double as
# "not passed" the way it does for the other optional arguments. The
# three states are read by identity: left out keeps the API's default,
# None removes the limit, an int sets it.
if max_area_count is not _UNSET:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

他の箇所では0が無制限、Noneがデフォルトという実装になっており、
_UNSET = デフォルト
という実装について理解に非常に時間がかかりました。
大抵の場合は0が無制限を表し、省略時はデフォルト値を表します。

今回の実装については、maxAreaCountのdb側が無制限= nullだからUNSETという新しい概念を追加したという判断で合ってますか?
かなり特殊な実装なので、なぜこのような実装になっているかコメントがほしいです。次に実装する人が混乱します

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご認識のとおりです。maxAreaCount は無制限が null で、null 自体が API へ送る値になるため None が「未指定」を兼ねられず、省略 / null / 整数の三値になっています。

他の項目の二値の慣習から外れる点はご指摘どおりなので、マーカーの定義側と create / update 双方の使用箇所に、三値であることとそれぞれの意味をコメントで残しました。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxAreaCount は無制限が null で、null 自体が API へ送る値になるため None が「未指定」を兼ねられず、省略 / null / 整数の三値になっています。
という点がコメントで確認できませんでした。

この点がないと、次の実装者がdbまで見に行かないと実装意図がわかりません

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sdkは全てのコメントが英語で書かれているので英語でお願いしますー

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

英語に戻しました!

payload["maxAreaCount"] = max_area_count
return self.api.post_request(endpoint, payload=payload)

def create_classification_annotation(self, project: str, attributes: list) -> str:
Expand All @@ -4343,6 +4388,7 @@ def update_annotation(
color: str = None,
order: int = None,
attributes: list = [],
max_area_count: Optional[int] = _UNSET,
) -> str:
"""
Update an annotation.
Expand All @@ -4352,6 +4398,10 @@ def update_annotation(
title is a display name of value (Optional).
color is hex color code like #ffffff (Optional).
attributes is a list of attribute (Optional).
max_area_count is the maximum number of separate regions a single
segmentation annotation may consist of, between 1 and 1000 (Optional).
It only applies to segmentation classes and is left unchanged when
omitted. Set None to allow any number of regions.
"""
endpoint = "annotations/" + annotation_id
payload = {}
Expand All @@ -4365,6 +4415,13 @@ def update_annotation(
payload["order"] = order
if attributes:
payload["attributes"] = attributes
# maxAreaCount represents "unlimited" as null rather than as 0, so
# passing None is itself a meaningful call and None cannot double as
# "not passed" the way it does for the other optional arguments. The
# three states are read by identity: left out keeps the stored value,
# None removes the limit, an int sets it.
if max_area_count is not _UNSET:
payload["maxAreaCount"] = max_area_count
return self.api.put_request(endpoint, payload=payload)

def update_classification_annotation(
Expand Down
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import numpy as np
import pytest

import fastlabel


def _write_synthetic_video(
path: Path,
Expand Down Expand Up @@ -48,3 +50,26 @@ def _factory(
)

return _factory


@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("FASTLABEL_ACCESS_TOKEN", "dummy-token")
return fastlabel.Client()


@pytest.fixture
def capture_request(monkeypatch):
"""Replace an api.*_request method with a recorder and return the calls list."""

def _factory(client, method_name, return_value=None):
calls = []

def fake(endpoint, *args, **kwargs):
calls.append({"endpoint": endpoint, "args": args, "kwargs": kwargs})
return return_value

monkeypatch.setattr(client.api, method_name, fake)
return calls

return _factory
119 changes: 119 additions & 0 deletions tests/test_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Tests for the annotation class API client methods.

These verify that create_annotation and update_annotation build the correct
endpoint and payload, with a focus on max_area_count. The HTTP layer
(client.api.*_request) is stubbed so no real request is made.
"""

import copy
import pickle

import pytest

import fastlabel

# --- create_annotation -----------------------------------------------------


def test_create_annotation_omits_max_area_count_by_default(client, capture_request):
calls = capture_request(client, "post_request", return_value="anno-id")

client.create_annotation(
project="my-project", type="segmentation", value="cat", title="Cat"
)

# The field is left out entirely so the API applies its own default of 1
assert calls[0]["endpoint"] == "annotations"
assert calls[0]["kwargs"]["payload"] == {
"project": "my-project",
"type": "segmentation",
"value": "cat",
"title": "Cat",
}


def test_create_annotation_with_max_area_count(client, capture_request):
calls = capture_request(client, "post_request", return_value="anno-id")

client.create_annotation(
project="my-project",
type="segmentation",
value="cat",
title="Cat",
max_area_count=10,
)

assert calls[0]["kwargs"]["payload"]["maxAreaCount"] == 10


def test_create_annotation_without_max_area_count_limit(client, capture_request):
calls = capture_request(client, "post_request", return_value="anno-id")

client.create_annotation(
project="my-project",
type="segmentation",
value="cat",
title="Cat",
max_area_count=None,
)

# None is sent as an explicit null, which means no limit on the server side
assert calls[0]["kwargs"]["payload"]["maxAreaCount"] is None


# --- update_annotation -----------------------------------------------------


def test_update_annotation_omits_max_area_count_by_default(client, capture_request):
calls = capture_request(client, "put_request", return_value="anno-id")

client.update_annotation(annotation_id="anno-id", title="Cat")

assert calls[0]["endpoint"] == "annotations/anno-id"
assert calls[0]["kwargs"]["payload"] == {"title": "Cat"}


def test_update_annotation_with_max_area_count(client, capture_request):
calls = capture_request(client, "put_request", return_value="anno-id")

client.update_annotation(annotation_id="anno-id", max_area_count=10)

assert calls[0]["kwargs"]["payload"] == {"maxAreaCount": 10}


def test_update_annotation_without_max_area_count_limit(client, capture_request):
calls = capture_request(client, "put_request", return_value="anno-id")

client.update_annotation(annotation_id="anno-id", max_area_count=None)

# None is sent as an explicit null, which means no limit on the server side
assert calls[0]["kwargs"]["payload"] == {"maxAreaCount": None}


# --- the "not passed" marker -----------------------------------------------


@pytest.mark.parametrize(
"round_trip",
[copy.deepcopy, lambda value: pickle.loads(pickle.dumps(value))],
ids=["deepcopy", "pickle"],
)
def test_create_annotation_keeps_marker_meaning_after_round_trip(
client, capture_request, round_trip
):
calls = capture_request(client, "post_request", return_value="anno-id")

# Callers that collect keyword arguments and pass them around must still
# get "omitted" out of the default, which relies on the marker's identity
kwargs = round_trip(
{
"project": "my-project",
"type": "segmentation",
"value": "cat",
"title": "Cat",
"max_area_count": fastlabel._UNSET,
}
)
client.create_annotation(**kwargs)

assert "maxAreaCount" not in calls[0]["kwargs"]["payload"]
Loading