-
Notifications
You must be signed in to change notification settings - Fork 4
アノテーションクラスの領域数上限をSDKから指定できるようにする #288
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
88eba82
bd6216d
bcb88fd
b098470
d1efbf8
1aa65b6
961462d
f6a5497
a990f6b
8b77875
fb6b53d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,38 @@ | |
| ) | ||
|
|
||
|
|
||
| class _Unset: | ||
| """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 | ||
|
|
||
|
|
@@ -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. | ||
|
|
@@ -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 = { | ||
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 他の箇所では0が無制限、Noneがデフォルトという実装になっており、 今回の実装については、maxAreaCountのdb側が無制限= nullだからUNSETという新しい概念を追加したという判断で合ってますか?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ご認識のとおりです。maxAreaCount は無制限が null で、null 自体が API へ送る値になるため None が「未指定」を兼ねられず、省略 / null / 整数の三値になっています。 他の項目の二値の慣習から外れる点はご指摘どおりなので、マーカーの定義側と create / update 双方の使用箇所に、三値であることとそれぞれの意味をコメントで残しました。 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maxAreaCount は無制限が null で、null 自体が API へ送る値になるため None が「未指定」を兼ねられず、省略 / null / 整数の三値になっています。 この点がないと、次の実装者がdbまで見に行かないと実装意図がわかりません There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sdkは全てのコメントが英語で書かれているので英語でお願いしますー
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
@@ -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. | ||
|
|
@@ -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 = {} | ||
|
|
@@ -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( | ||
|
|
||
| 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"] |
There was a problem hiding this comment.
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 に載ってしまうので。
シングルトンとしては普通にこんな感じ
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
シングルトンにしました。deepcopy / pickle を往復しても
is判定が保たれ、payload にマーカーが載らないことをテストで固定しています。