dragoneye-python 0.4.1__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/PKG-INFO +2 -1
  2. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/PKG-INFO +2 -1
  3. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/SOURCES.txt +1 -0
  4. dragoneye_python-0.4.1/requirements.txt → dragoneye_python-1.0.0/dragoneye_python.egg-info/requires.txt +2 -1
  5. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/pyproject.toml +1 -1
  6. dragoneye_python-0.4.1/dragoneye_python.egg-info/requires.txt → dragoneye_python-1.0.0/requirements.txt +1 -0
  7. dragoneye_python-1.0.0/src/dragoneye/__init__.py +44 -0
  8. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/src/dragoneye/classification.py +57 -15
  9. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/src/dragoneye/models.py +26 -11
  10. dragoneye_python-1.0.0/src/dragoneye/parquet_deserializer.py +136 -0
  11. dragoneye_python-1.0.0/src/dragoneye/types/common.py +12 -0
  12. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/src/dragoneye/types/media.py +4 -4
  13. dragoneye_python-0.4.1/src/dragoneye/__init__.py +0 -25
  14. dragoneye_python-0.4.1/src/dragoneye/types/common.py +0 -31
  15. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/README.md +0 -0
  16. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/dependency_links.txt +0 -0
  17. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/top_level.txt +0 -0
  18. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/setup.cfg +0 -0
  19. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/src/dragoneye/client.py +0 -0
  20. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/src/dragoneye/constants.py +0 -0
  21. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/src/dragoneye/types/__init__.py +0 -0
  22. {dragoneye_python-0.4.1 → dragoneye_python-1.0.0}/src/dragoneye/types/exception.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dragoneye-python
3
- Version: 0.4.1
3
+ Version: 1.0.0
4
4
  License-Expression: MIT
5
5
  Requires-Python: >=3.8
6
6
  Requires-Dist: requests
@@ -8,3 +8,4 @@ Requires-Dist: pydantic>=2
8
8
  Requires-Dist: typing-extensions>=4.0.0
9
9
  Requires-Dist: backoff>=2.0.0
10
10
  Requires-Dist: aiohttp
11
+ Requires-Dist: polars>=1.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dragoneye-python
3
- Version: 0.4.1
3
+ Version: 1.0.0
4
4
  License-Expression: MIT
5
5
  Requires-Python: >=3.8
6
6
  Requires-Dist: requests
@@ -8,3 +8,4 @@ Requires-Dist: pydantic>=2
8
8
  Requires-Dist: typing-extensions>=4.0.0
9
9
  Requires-Dist: backoff>=2.0.0
10
10
  Requires-Dist: aiohttp
11
+ Requires-Dist: polars>=1.0.0
@@ -11,6 +11,7 @@ src/dragoneye/classification.py
11
11
  src/dragoneye/client.py
12
12
  src/dragoneye/constants.py
13
13
  src/dragoneye/models.py
14
+ src/dragoneye/parquet_deserializer.py
14
15
  src/dragoneye/types/__init__.py
15
16
  src/dragoneye/types/common.py
16
17
  src/dragoneye/types/exception.py
@@ -2,4 +2,5 @@ requests
2
2
  pydantic>=2
3
3
  typing-extensions>=4.0.0
4
4
  backoff>=2.0.0
5
- aiohttp
5
+ aiohttp
6
+ polars>=1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dragoneye-python"
7
- version = "0.4.1"
7
+ version = "1.0.0"
8
8
  requires-python = ">=3.8"
9
9
  dynamic = ["dependencies"]
10
10
  license = "MIT"
@@ -3,3 +3,4 @@ pydantic>=2
3
3
  typing-extensions>=4.0.0
4
4
  backoff>=2.0.0
5
5
  aiohttp
6
+ polars>=1.0.0
@@ -0,0 +1,44 @@
1
+ from .classification import (
2
+ Classification,
3
+ )
4
+ from .client import Dragoneye
5
+ from .models import (
6
+ ClassificationAttributeOption,
7
+ ClassificationAttributeResponse,
8
+ ClassificationCategory,
9
+ ClassificationCategoryPrediction,
10
+ ClassificationObjectPrediction,
11
+ ClassificationPredictImageResponse,
12
+ ClassificationPredictVideoResponse,
13
+ ClassificationVideoObjectPrediction,
14
+ )
15
+ from .types.common import NormalizedBbox
16
+ from .types.exception import (
17
+ PredictionTaskBeginError,
18
+ PredictionTaskError,
19
+ PredictionTaskResultsUnavailableError,
20
+ PredictionTimeoutException,
21
+ PredictionUploadError,
22
+ )
23
+ from .types.media import Image, Video
24
+
25
+ __all__ = [
26
+ "Classification",
27
+ "ClassificationAttributeOption",
28
+ "ClassificationAttributeResponse",
29
+ "ClassificationCategory",
30
+ "ClassificationCategoryPrediction",
31
+ "ClassificationObjectPrediction",
32
+ "ClassificationPredictImageResponse",
33
+ "ClassificationPredictVideoResponse",
34
+ "ClassificationVideoObjectPrediction",
35
+ "Dragoneye",
36
+ "Image",
37
+ "NormalizedBbox",
38
+ "PredictionTaskBeginError",
39
+ "PredictionTaskError",
40
+ "PredictionTaskResultsUnavailableError",
41
+ "PredictionTimeoutException",
42
+ "PredictionUploadError",
43
+ "Video",
44
+ ]
@@ -2,10 +2,12 @@ import asyncio
2
2
  import logging
3
3
  import time
4
4
  from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, overload
5
+ from urllib.parse import urlencode
5
6
 
6
7
  import aiohttp
7
8
  import backoff
8
9
  from aiohttp import ClientError
10
+ from multidict import CIMultiDict, CIMultiDictProxy
9
11
  from pydantic import BaseModel
10
12
 
11
13
  from .constants import FAILED_STATUS_PREFIX, PREDICTED_STATUS
@@ -14,6 +16,10 @@ from .models import (
14
16
  ClassificationPredictVideoResponse,
15
17
  PredictionTaskStatusResponse,
16
18
  )
19
+ from .parquet_deserializer import (
20
+ deserialize_image_predictions,
21
+ deserialize_video_predictions,
22
+ )
17
23
  from .types.common import (
18
24
  BASE_API_URL,
19
25
  PredictionTaskState,
@@ -129,8 +135,9 @@ class Classification:
129
135
  """
130
136
  Given a prediction task UUID, return
131
137
  """
132
- url = f"{BASE_API_URL}/prediction-task/status?predictionTaskUuid={prediction_task_uuid}"
133
- headers = {"Authorization": f"Bearer {self._client.api_key}"}
138
+ query = urlencode({"predictionTaskUuid": prediction_task_uuid})
139
+ url = f"{BASE_API_URL}/prediction-task/status?{query}"
140
+ headers = {"X-API-Key": self._client.api_key}
134
141
 
135
142
  @self._backoff_on_429
136
143
  async def _make_request():
@@ -187,31 +194,60 @@ class Classification:
187
194
  async def _get_results_unified(
188
195
  self, prediction_task_uuid: PredictionTaskUUID, prediction_type: PredictionType
189
196
  ) -> Union[ClassificationPredictImageResponse, ClassificationPredictVideoResponse]:
190
- url = f"{BASE_API_URL}/prediction-task/results?predictionTaskUuid={prediction_task_uuid}"
191
- headers = {"Authorization": f"Bearer {self._client.api_key}"}
197
+ query = urlencode(
198
+ {
199
+ "predictionTaskUuid": prediction_task_uuid,
200
+ "response_version": "parquet",
201
+ }
202
+ )
203
+ url = f"{BASE_API_URL}/prediction-task/results?{query}"
204
+ headers = {"X-API-Key": self._client.api_key}
192
205
 
193
206
  @self._backoff_on_429
194
- async def _make_request():
207
+ async def _make_request() -> tuple[bytes, CIMultiDictProxy[str]]:
195
208
  async with aiohttp.ClientSession() as session:
196
209
  async with session.get(url, headers=headers) as resp:
210
+ if resp.status == 400:
211
+ payload = await resp.json()
212
+ raise PredictionTaskResultsUnavailableError(
213
+ payload.get("detail", "")
214
+ )
197
215
  resp.raise_for_status()
198
- payload = await resp.json()
199
- return payload
216
+ body_bytes = await resp.read()
217
+ response_headers = CIMultiDictProxy(CIMultiDict(resp.headers))
218
+ return body_bytes, response_headers
200
219
 
201
220
  try:
202
- payload = await _make_request()
221
+ parquet_bytes, response_headers = await _make_request()
222
+ except PredictionTaskResultsUnavailableError:
223
+ raise
203
224
  except ClientError as error:
204
225
  raise PredictionTaskResultsUnavailableError(
205
226
  f"Error getting prediction task results: {error}"
206
227
  )
207
228
 
208
- # Add the prediction task uuid to the response before returning
209
- payload["prediction_task_uuid"] = prediction_task_uuid
229
+ original_file_name = response_headers.get("X-Original-File-Name")
210
230
 
211
231
  if prediction_type == "image":
212
- return ClassificationPredictImageResponse.model_validate(payload)
232
+ return ClassificationPredictImageResponse(
233
+ object_predictions=deserialize_image_predictions(parquet_bytes),
234
+ prediction_task_uuid=prediction_task_uuid,
235
+ original_file_name=original_file_name,
236
+ )
213
237
  elif prediction_type == "video":
214
- return ClassificationPredictVideoResponse.model_validate(payload)
238
+ frames_per_second_header = response_headers.get("X-Frames-Per-Second")
239
+ if frames_per_second_header is None:
240
+ raise PredictionTaskResultsUnavailableError(
241
+ "Missing X-Frames-Per-Second header on video prediction response"
242
+ )
243
+ return ClassificationPredictVideoResponse(
244
+ timestamp_us_to_predictions=deserialize_video_predictions(
245
+ parquet_bytes
246
+ ),
247
+ frames_per_second=int(frames_per_second_header),
248
+ prediction_task_uuid=prediction_task_uuid,
249
+ original_file_name=original_file_name,
250
+ )
215
251
  else:
216
252
  raise ValueError(f"Unsupported prediction type: {prediction_type}")
217
253
 
@@ -255,13 +291,13 @@ class Classification:
255
291
  )
256
292
 
257
293
  predict_url = f"{BASE_API_URL}/predict"
258
- predict_data = {
294
+ predict_data: dict[str, Any] = {
259
295
  "model_name": model_name,
260
296
  "prediction_task_uuid": prediction_task_begin_response.prediction_task_uuid,
261
297
  **kwargs,
262
298
  }
263
299
  predict_headers = {
264
- "Authorization": f"Bearer {self._client.api_key}",
300
+ "X-API-Key": self._client.api_key,
265
301
  }
266
302
 
267
303
  @self._backoff_on_429
@@ -354,6 +390,7 @@ class Classification:
354
390
 
355
391
  form_data = aiohttp.FormData()
356
392
  form_data.add_field("mimetype", mime_type)
393
+ form_data.add_field("response_version", "parquet")
357
394
  if file_name is not None:
358
395
  form_data.add_field("file_name", file_name)
359
396
 
@@ -361,19 +398,24 @@ class Classification:
361
398
  form_data.add_field("frames_per_second", str(frames_per_second))
362
399
 
363
400
  headers = {
364
- "Authorization": f"Bearer {self._client.api_key}",
401
+ "X-API-Key": self._client.api_key,
365
402
  }
366
403
 
367
404
  @self._backoff_on_429
368
405
  async def _make_request():
369
406
  async with aiohttp.ClientSession() as session:
370
407
  async with session.post(url, data=form_data, headers=headers) as resp:
408
+ if resp.status == 400:
409
+ error_payload = await resp.json()
410
+ raise PredictionTaskBeginError(error_payload.get("detail", ""))
371
411
  resp.raise_for_status()
372
412
  payload = await resp.json()
373
413
  return payload
374
414
 
375
415
  try:
376
416
  payload = await _make_request()
417
+ except PredictionTaskBeginError:
418
+ raise
377
419
  except ClientError as error:
378
420
  raise PredictionTaskBeginError("Error beginning prediction task:", error)
379
421
 
@@ -1,4 +1,4 @@
1
- from typing import Dict, Sequence
1
+ from typing import Dict, List, Optional
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
@@ -7,8 +7,6 @@ from dragoneye.types.common import (
7
7
  PredictionTaskState,
8
8
  PredictionTaskUUID,
9
9
  PredictionType,
10
- TaxonID,
11
- TaxonPrediction,
12
10
  )
13
11
 
14
12
 
@@ -18,22 +16,38 @@ class PredictionTaskStatusResponse(BaseModel):
18
16
  status: PredictionTaskState
19
17
 
20
18
 
21
- class ClassificationTraitRootPrediction(BaseModel):
22
- id: TaxonID
19
+ class ClassificationAttributeOption(BaseModel):
20
+ option_id: int
23
21
  name: str
24
- displayName: str
25
- taxons: Sequence[TaxonPrediction]
22
+ score: float
23
+
24
+
25
+ class ClassificationAttributeResponse(BaseModel):
26
+ attribute_id: int
27
+ name: str
28
+ options: List[ClassificationAttributeOption]
29
+
30
+
31
+ class ClassificationCategory(BaseModel):
32
+ id: int
33
+ name: str
34
+ score: float
35
+
36
+
37
+ class ClassificationCategoryPrediction(BaseModel):
38
+ category: ClassificationCategory
39
+ attributes: List[ClassificationAttributeResponse]
26
40
 
27
41
 
28
42
  class ClassificationObjectPrediction(BaseModel):
29
43
  normalizedBbox: NormalizedBbox
30
- category: TaxonPrediction
31
- traits: Sequence[ClassificationTraitRootPrediction]
44
+ predictions: List[ClassificationCategoryPrediction]
32
45
 
33
46
 
34
47
  class ClassificationPredictImageResponse(BaseModel):
35
- predictions: Sequence[ClassificationObjectPrediction]
48
+ object_predictions: List[ClassificationObjectPrediction]
36
49
  prediction_task_uuid: PredictionTaskUUID
50
+ original_file_name: Optional[str]
37
51
 
38
52
 
39
53
  class ClassificationVideoObjectPrediction(ClassificationObjectPrediction):
@@ -43,7 +57,8 @@ class ClassificationVideoObjectPrediction(ClassificationObjectPrediction):
43
57
 
44
58
  class ClassificationPredictVideoResponse(BaseModel):
45
59
  timestamp_us_to_predictions: Dict[
46
- int, Sequence[ClassificationVideoObjectPrediction]
60
+ int, List[ClassificationVideoObjectPrediction]
47
61
  ]
48
62
  frames_per_second: int
49
63
  prediction_task_uuid: PredictionTaskUUID
64
+ original_file_name: Optional[str]
@@ -0,0 +1,136 @@
1
+ """Convert a zstd-compressed parquet prediction blob into typed SDK models.
2
+
3
+ Matches the server-side schema produced by the dragoneye pipeline:
4
+
5
+ image_id: String
6
+ normalized_bbox: Array(Float64, 4)
7
+ bbox_score: Float64
8
+ predictions: List(Struct{
9
+ category_id: Int64,
10
+ name: String,
11
+ score: Float64,
12
+ attributes: List(Struct{
13
+ attribute_id: Int64,
14
+ name: String,
15
+ options: List(Struct{
16
+ option_id: Int64,
17
+ name: String,
18
+ score: Float64,
19
+ }),
20
+ }),
21
+ })
22
+
23
+ For video parquet blobs the extra column ``timestamp_microseconds: Int64`` is
24
+ present.
25
+ """
26
+
27
+ import io
28
+ from typing import Any, Dict, List
29
+
30
+ import polars as pl
31
+
32
+ from .models import (
33
+ ClassificationAttributeOption,
34
+ ClassificationAttributeResponse,
35
+ ClassificationCategory,
36
+ ClassificationCategoryPrediction,
37
+ ClassificationObjectPrediction,
38
+ ClassificationVideoObjectPrediction,
39
+ )
40
+ from .types.common import NormalizedBbox
41
+
42
+
43
+ def _predictions_to_models(
44
+ raw_predictions: List[Dict[str, Any]],
45
+ ) -> List[ClassificationCategoryPrediction]:
46
+ return [
47
+ ClassificationCategoryPrediction(
48
+ category=ClassificationCategory(
49
+ id=pred["category_id"],
50
+ name=pred["name"],
51
+ score=pred["score"],
52
+ ),
53
+ attributes=[
54
+ ClassificationAttributeResponse(
55
+ attribute_id=attr["attribute_id"],
56
+ name=attr["name"],
57
+ options=[
58
+ ClassificationAttributeOption(
59
+ option_id=opt["option_id"],
60
+ name=opt["name"],
61
+ score=opt["score"],
62
+ )
63
+ for opt in attr["options"]
64
+ ],
65
+ )
66
+ for attr in pred["attributes"]
67
+ ],
68
+ )
69
+ for pred in raw_predictions
70
+ ]
71
+
72
+
73
+ def _read_dataframe(parquet_bytes: bytes, columns: List[str]) -> pl.DataFrame:
74
+ return pl.read_parquet(io.BytesIO(parquet_bytes), columns=columns)
75
+
76
+
77
+ def deserialize_image_predictions(
78
+ parquet_bytes: bytes,
79
+ ) -> List[ClassificationObjectPrediction]:
80
+ df = _read_dataframe(
81
+ parquet_bytes, columns=["normalized_bbox", "predictions"]
82
+ ).filter(pl.col("normalized_bbox").is_not_null())
83
+ return [
84
+ ClassificationObjectPrediction(
85
+ normalizedBbox=NormalizedBbox(tuple(row["normalized_bbox"])),
86
+ predictions=_predictions_to_models(row["predictions"] or []),
87
+ )
88
+ for row in df.select("normalized_bbox", "predictions").iter_rows(named=True)
89
+ ]
90
+
91
+
92
+ def deserialize_video_predictions(
93
+ parquet_bytes: bytes,
94
+ ) -> Dict[int, List[ClassificationVideoObjectPrediction]]:
95
+ df = _read_dataframe(
96
+ parquet_bytes,
97
+ columns=[
98
+ "image_id",
99
+ "normalized_bbox",
100
+ "predictions",
101
+ "timestamp_microseconds",
102
+ ],
103
+ )
104
+
105
+ # Frames with detections contribute rows; frames without still appear with a
106
+ # null bbox so the caller can distinguish "no detections" from "frame not
107
+ # processed".
108
+ real_df = df.filter(pl.col("normalized_bbox").is_not_null())
109
+
110
+ result: Dict[int, List[ClassificationVideoObjectPrediction]] = {
111
+ int(ts): [
112
+ ClassificationVideoObjectPrediction(
113
+ normalizedBbox=NormalizedBbox(tuple(row["normalized_bbox"])),
114
+ predictions=_predictions_to_models(row["predictions"] or []),
115
+ frame_id=row["image_id"],
116
+ timestamp_microseconds=int(ts),
117
+ )
118
+ for row in group.select(
119
+ "normalized_bbox", "predictions", "image_id"
120
+ ).iter_rows(named=True)
121
+ ]
122
+ for (ts,), group in real_df.group_by(
123
+ "timestamp_microseconds",
124
+ )
125
+ }
126
+
127
+ # Include no-detection frames with empty prediction lists.
128
+ if len(df):
129
+ for row in df.select("timestamp_microseconds").unique().iter_rows(named=True):
130
+ ts_raw = row["timestamp_microseconds"]
131
+ if ts_raw is None:
132
+ continue
133
+ ts = int(ts_raw)
134
+ result.setdefault(ts, [])
135
+
136
+ return result
@@ -0,0 +1,12 @@
1
+ from typing import Literal, NewType, Tuple
2
+
3
+ PredictionType = Literal["image", "video"]
4
+ PredictionTaskState = NewType("PredictionTaskState", str)
5
+
6
+ NormalizedBbox = NewType("NormalizedBbox", Tuple[float, float, float, float])
7
+
8
+ PredictionTaskUUID = NewType("PredictionTaskUUID", str)
9
+
10
+ TimestampUs = NewType("TimestampUs", int)
11
+
12
+ BASE_API_URL = "https://api.dragoneye.ai"
@@ -136,10 +136,10 @@ class Media:
136
136
 
137
137
  # For any readable object with .read()
138
138
  if hasattr(src, "read"):
139
- pos = _tell_safe(src)
140
- data = src.read()
141
- _seek_safe(src, pos)
142
- return BytesIO(data)
139
+ pos = _tell_safe(src) # pyright: ignore [reportArgumentType]
140
+ data = src.read() # pyright: ignore [reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType]
141
+ _seek_safe(src, pos) # pyright: ignore [reportArgumentType]
142
+ return BytesIO(data) # pyright: ignore [reportUnknownArgumentType]
143
143
 
144
144
  raise TypeError(
145
145
  "Invalid media source: expected bytes, BytesIO, or a readable binary stream."
@@ -1,25 +0,0 @@
1
- from .classification import (
2
- Classification,
3
- )
4
- from .client import Dragoneye
5
- from .models import (
6
- ClassificationObjectPrediction,
7
- ClassificationPredictImageResponse,
8
- ClassificationTraitRootPrediction,
9
- )
10
- from .types.common import NormalizedBbox, TaxonID, TaxonPrediction, TaxonType
11
- from .types.media import Image, Video
12
-
13
- __all__ = [
14
- "Classification",
15
- "ClassificationObjectPrediction",
16
- "ClassificationPredictImageResponse",
17
- "ClassificationTraitRootPrediction",
18
- "Dragoneye",
19
- "Image",
20
- "Video",
21
- "NormalizedBbox",
22
- "TaxonID",
23
- "TaxonPrediction",
24
- "TaxonType",
25
- ]
@@ -1,31 +0,0 @@
1
- from enum import Enum
2
- from typing import Literal, NewType, Optional, Sequence, Tuple
3
-
4
- from pydantic import BaseModel
5
-
6
- PredictionType = Literal["image", "video"]
7
- PredictionTaskState = NewType("PredictionTaskState", str)
8
-
9
- NormalizedBbox = NewType("NormalizedBbox", Tuple[float, float, float, float])
10
-
11
-
12
- class TaxonType(str, Enum):
13
- CATEGORY = ("category",)
14
- TRAIT = ("trait",)
15
-
16
-
17
- TaxonID = NewType("TaxonID", int)
18
-
19
- PredictionTaskUUID = NewType("PredictionTaskUUID", str)
20
-
21
-
22
- class TaxonPrediction(BaseModel):
23
- id: TaxonID
24
- type: TaxonType
25
- name: str
26
- displayName: str
27
- score: Optional[float]
28
- children: Sequence["TaxonPrediction"]
29
-
30
-
31
- BASE_API_URL = "https://api.dragoneye.ai"