dragoneye-python 0.5.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 (20) hide show
  1. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/PKG-INFO +2 -1
  2. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/PKG-INFO +2 -1
  3. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/SOURCES.txt +1 -0
  4. dragoneye_python-0.5.1/requirements.txt → dragoneye_python-1.0.0/dragoneye_python.egg-info/requires.txt +2 -1
  5. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/pyproject.toml +1 -1
  6. dragoneye_python-0.5.1/dragoneye_python.egg-info/requires.txt → dragoneye_python-1.0.0/requirements.txt +1 -0
  7. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/__init__.py +12 -0
  8. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/classification.py +56 -15
  9. dragoneye_python-1.0.0/src/dragoneye/parquet_deserializer.py +136 -0
  10. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/README.md +0 -0
  11. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/dependency_links.txt +0 -0
  12. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/dragoneye_python.egg-info/top_level.txt +0 -0
  13. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/setup.cfg +0 -0
  14. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/client.py +0 -0
  15. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/constants.py +0 -0
  16. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/models.py +0 -0
  17. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/types/__init__.py +0 -0
  18. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/types/common.py +0 -0
  19. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/types/exception.py +0 -0
  20. {dragoneye_python-0.5.1 → dragoneye_python-1.0.0}/src/dragoneye/types/media.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dragoneye-python
3
- Version: 0.5.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.5.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.5.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
@@ -13,6 +13,13 @@ from .models import (
13
13
  ClassificationVideoObjectPrediction,
14
14
  )
15
15
  from .types.common import NormalizedBbox
16
+ from .types.exception import (
17
+ PredictionTaskBeginError,
18
+ PredictionTaskError,
19
+ PredictionTaskResultsUnavailableError,
20
+ PredictionTimeoutException,
21
+ PredictionUploadError,
22
+ )
16
23
  from .types.media import Image, Video
17
24
 
18
25
  __all__ = [
@@ -28,5 +35,10 @@ __all__ = [
28
35
  "Dragoneye",
29
36
  "Image",
30
37
  "NormalizedBbox",
38
+ "PredictionTaskBeginError",
39
+ "PredictionTaskError",
40
+ "PredictionTaskResultsUnavailableError",
41
+ "PredictionTimeoutException",
42
+ "PredictionUploadError",
31
43
  "Video",
32
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
 
@@ -261,7 +297,7 @@ class Classification:
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,7 +390,7 @@ class Classification:
354
390
 
355
391
  form_data = aiohttp.FormData()
356
392
  form_data.add_field("mimetype", mime_type)
357
- form_data.add_field("response_version", "object")
393
+ form_data.add_field("response_version", "parquet")
358
394
  if file_name is not None:
359
395
  form_data.add_field("file_name", file_name)
360
396
 
@@ -362,19 +398,24 @@ class Classification:
362
398
  form_data.add_field("frames_per_second", str(frames_per_second))
363
399
 
364
400
  headers = {
365
- "Authorization": f"Bearer {self._client.api_key}",
401
+ "X-API-Key": self._client.api_key,
366
402
  }
367
403
 
368
404
  @self._backoff_on_429
369
405
  async def _make_request():
370
406
  async with aiohttp.ClientSession() as session:
371
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", ""))
372
411
  resp.raise_for_status()
373
412
  payload = await resp.json()
374
413
  return payload
375
414
 
376
415
  try:
377
416
  payload = await _make_request()
417
+ except PredictionTaskBeginError:
418
+ raise
378
419
  except ClientError as error:
379
420
  raise PredictionTaskBeginError("Error beginning prediction task:", error)
380
421
 
@@ -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