ace-engine-sdk 0.2.0__py3-none-any.whl

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.
ace_engine/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .client import AceClient
2
+ from .results import AceError, BinaryResult
3
+
4
+ Client = AceClient
5
+ __version__ = "0.2.0"
6
+ __all__ = ["AceClient", "Client", "AceError", "BinaryResult", "__version__"]
ace_engine/client.py ADDED
@@ -0,0 +1,676 @@
1
+ """Public ACE APIs: private assets, billed inferences, IQA jobs and file APIs."""
2
+
3
+ import mimetypes
4
+ import re
5
+ import time
6
+ import uuid
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ import requests
11
+ from requests.adapters import HTTPAdapter
12
+
13
+ from .results import AceError, BinaryResult
14
+
15
+
16
+ class AceClient:
17
+ """Synchronous client. Methods return server envelopes unless strict mode is enabled.
18
+
19
+ Instances own a Session and should not be shared between concurrent threads.
20
+ No request is automatically retried, and redirects never forward credentials.
21
+ """
22
+
23
+ MODELS = frozenset(("mnet", "icaa17k"))
24
+ IQA_METRICS = frozenset(
25
+ (
26
+ "sharpness",
27
+ "color_accuracy",
28
+ "saturation",
29
+ "exposure",
30
+ "contrast",
31
+ "noise",
32
+ "holistic",
33
+ "overall_iqa",
34
+ )
35
+ )
36
+ DEFAULT_IQA_METRICS = ("sharpness", "exposure", "contrast", "noise")
37
+ PENDING_TASKS = frozenset(("QUEUED", "RUNNING", "CANCEL_REQUESTED"))
38
+ TERMINAL_TASKS = frozenset(("SUCCEEDED", "PARTIAL", "FAILED", "CANCELLED"))
39
+
40
+ def __init__(
41
+ self,
42
+ base_url="https://ace-aesthetics.cn",
43
+ access_key_id=None,
44
+ sign=None,
45
+ timeout=120,
46
+ *,
47
+ access_key_secret=None,
48
+ token=None,
49
+ api_prefix="/ibt",
50
+ raise_for_errors=False,
51
+ ):
52
+ if not isinstance(base_url, str):
53
+ raise ValueError("base_url must be an HTTP(S) URL")
54
+ parsed = urlsplit(base_url)
55
+ if (
56
+ parsed.scheme not in ("https", "http")
57
+ or not parsed.hostname
58
+ or parsed.username
59
+ or parsed.password
60
+ or parsed.query
61
+ or parsed.fragment
62
+ or any(c.isspace() for c in base_url)
63
+ ):
64
+ raise ValueError("base_url must be HTTP(S), without credentials, query or fragment")
65
+ if sign is not None and access_key_secret is not None and sign != access_key_secret:
66
+ raise ValueError("sign and access_key_secret must match when both are supplied")
67
+ secret = access_key_secret if access_key_secret is not None else sign
68
+ if token is not None and (access_key_id is not None or secret is not None):
69
+ raise ValueError("Use either API-Key credentials or a user token, not both")
70
+ credentials = (token,) if token is not None else (access_key_id, secret)
71
+ if any(
72
+ not isinstance(value, str) or not value.strip() or "\r" in value or "\n" in value
73
+ for value in credentials
74
+ ):
75
+ raise ValueError("Provide a complete API-Key pair or a user token")
76
+ if not isinstance(api_prefix, str) or (
77
+ api_prefix and not re.fullmatch(r"(?:/[A-Za-z0-9_-]+)+/?", api_prefix)
78
+ ):
79
+ raise ValueError("api_prefix must be empty or a root-relative path")
80
+ if not self._positive(timeout):
81
+ raise ValueError("timeout must be a positive number of seconds")
82
+ self.base_url = base_url.rstrip("/")
83
+ prefix = api_prefix.rstrip("/")
84
+ self.api_url = (
85
+ self.base_url
86
+ if not prefix or self.base_url.endswith(prefix)
87
+ else self.base_url + prefix
88
+ )
89
+ self.access_key_id, self.sign, self.token = access_key_id, secret, token
90
+ self.timeout = timeout
91
+ self.raise_for_errors = raise_for_errors
92
+ self.session = requests.Session()
93
+ self.session.mount("https://", HTTPAdapter(max_retries=0))
94
+ self.session.mount("http://", HTTPAdapter(max_retries=0))
95
+ self._closed = False
96
+
97
+ @staticmethod
98
+ def _positive(value):
99
+ return (
100
+ isinstance(value, (int, float))
101
+ and not isinstance(value, bool)
102
+ and 0 < value < float("inf")
103
+ )
104
+
105
+ @staticmethod
106
+ def new_idempotency_key():
107
+ return str(uuid.uuid4())
108
+
109
+ @staticmethod
110
+ def _ok(value):
111
+ return (
112
+ isinstance(value, dict)
113
+ and str(value.get("code")) == "200"
114
+ and value.get("success") is not False
115
+ )
116
+
117
+ def _redact(self, value):
118
+ if isinstance(value, str):
119
+ for credential in (self.sign, self.token, self.access_key_id):
120
+ if credential:
121
+ value = value.replace(credential, "[REDACTED]")
122
+ return value
123
+ if isinstance(value, list):
124
+ return [self._redact(item) for item in value]
125
+ if isinstance(value, dict):
126
+ return {key: self._redact(item) for key, item in value.items()}
127
+ return value
128
+
129
+ def _finish(self, value):
130
+ if self._ok(value):
131
+ return value
132
+ value = self._redact(value)
133
+ if self.raise_for_errors:
134
+ raise AceError(value)
135
+ return value
136
+
137
+ def _failure(self, code, message, *, context=None, error_type="client", **extra):
138
+ value = {
139
+ "code": str(code),
140
+ "success": False,
141
+ "message": message,
142
+ "data": None,
143
+ "errorType": error_type,
144
+ **extra,
145
+ }
146
+ if context:
147
+ value["meta"] = dict(context)
148
+ return self._finish(value)
149
+
150
+ def _key(self, value):
151
+ key = self.new_idempotency_key() if value is None else value
152
+ if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", key):
153
+ raise ValueError(
154
+ "idempotency_key must contain 1-128 letters, digits, '.', '_', ':' or '-'"
155
+ )
156
+ return key
157
+
158
+ @staticmethod
159
+ def _identifier(value):
160
+ if not isinstance(value, str) or str(uuid.UUID(value)) != value:
161
+ raise ValueError("Expected a canonical UUID")
162
+ return value
163
+
164
+ def _request(
165
+ self,
166
+ method,
167
+ path,
168
+ *,
169
+ data=None,
170
+ files=None,
171
+ json=None,
172
+ context=None,
173
+ binary=None,
174
+ legacy=False,
175
+ timeout=None,
176
+ ):
177
+ if self._closed:
178
+ return self._failure(400, "Client is closed", context=context)
179
+ headers = {"Accept": "application/json"}
180
+ if self.token:
181
+ headers["Authorization"] = "Bearer " + self.token
182
+ elif not legacy:
183
+ headers.update({"X-API-Key-Id": self.access_key_id, "X-API-Key-Secret": self.sign})
184
+ if context and context.get("idempotencyKey"):
185
+ headers["Idempotency-Key"] = context["idempotencyKey"]
186
+ if binary:
187
+ headers["Accept"] = binary + "/*, application/json"
188
+ try:
189
+ response = self.session.request(
190
+ method,
191
+ self.api_url + path,
192
+ headers=headers,
193
+ data=data,
194
+ files=files,
195
+ json=json,
196
+ timeout=self.timeout if timeout is None else timeout,
197
+ allow_redirects=False,
198
+ )
199
+ except requests.RequestException as error:
200
+ return self._failure(
201
+ 500,
202
+ "Network request failed ({})".format(type(error).__name__),
203
+ context=context,
204
+ error_type="network",
205
+ retryable=method == "GET" or bool(context and context.get("idempotencyKey")),
206
+ outcomeUnknown=method != "GET",
207
+ httpStatus=None,
208
+ )
209
+ try:
210
+ status = response.status_code
211
+ content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
212
+ request_id = response.headers.get("X-Request-Id")
213
+ if (
214
+ binary
215
+ and 200 <= status < 300
216
+ and (
217
+ content_type.startswith(binary + "/")
218
+ or content_type == "application/octet-stream"
219
+ )
220
+ ):
221
+ return BinaryResult(response.content, content_type, request_id)
222
+ try:
223
+ value = response.json()
224
+ except ValueError:
225
+ return self._failure(
226
+ status if status >= 300 else 502,
227
+ "Unexpected non-JSON response (HTTP {})".format(status),
228
+ context=context,
229
+ error_type="protocol",
230
+ httpStatus=status,
231
+ retryable=status in (429, 502, 503, 504),
232
+ outcomeUnknown=method != "GET",
233
+ )
234
+ if not isinstance(value, dict):
235
+ return self._failure(
236
+ 502,
237
+ "Expected a JSON response object",
238
+ context=context,
239
+ error_type="protocol",
240
+ httpStatus=status,
241
+ )
242
+ value = dict(value)
243
+ meta = value.get("meta")
244
+ value["meta"] = {
245
+ **(dict(context) if context else {}),
246
+ **(meta if isinstance(meta, dict) else {}),
247
+ }
248
+ if request_id:
249
+ value["meta"].setdefault("requestId", request_id)
250
+ if not 200 <= status < 300:
251
+ value.update({"success": False, "httpStatus": status})
252
+ if str(value.get("code", "200")) == "200":
253
+ value["code"] = str(status)
254
+ value.setdefault("message", "HTTP {}".format(status))
255
+ value.setdefault("retryable", status in (429, 502, 503, 504))
256
+ value.setdefault("errorType", "http")
257
+ elif not self._ok(value):
258
+ value.setdefault("code", "502")
259
+ value["success"] = False
260
+ value.setdefault("message", "Server did not confirm success")
261
+ value.setdefault("errorType", "business")
262
+ value["httpStatus"] = status
263
+ elif binary:
264
+ return self._failure(
265
+ 502,
266
+ "Expected binary output but received JSON",
267
+ context=context,
268
+ error_type="protocol",
269
+ httpStatus=status,
270
+ )
271
+ return self._finish(value)
272
+ finally:
273
+ response.close()
274
+
275
+ def _upload(
276
+ self, path, file_path, *, fields=None, kind="image", context=None, legacy=False, binary=None
277
+ ):
278
+ try:
279
+ file = Path(file_path)
280
+ if not file.is_file():
281
+ return self._failure(
282
+ 404, "Input file does not exist", context=context, error_type="file"
283
+ )
284
+ size = file.stat().st_size
285
+ limit = (100 if kind == "video" else 20) * 1024 * 1024
286
+ if not 0 < size <= limit:
287
+ return self._failure(
288
+ 413 if size > limit else 400,
289
+ "Input file is empty or exceeds the size limit",
290
+ context=context,
291
+ error_type="file",
292
+ )
293
+ media = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
294
+ with file.open("rb") as source:
295
+ return self._request(
296
+ "POST",
297
+ path,
298
+ files={"file": (file.name, source, media)},
299
+ data=fields,
300
+ legacy=legacy,
301
+ context=context,
302
+ binary=binary,
303
+ )
304
+ except (OSError, TypeError, ValueError):
305
+ return self._failure(
306
+ 400, "Input file cannot be read", context=context, error_type="file"
307
+ )
308
+
309
+ def upload_asset(self, image_path):
310
+ """Upload a private image, returning data.assetId. No inference is started."""
311
+ return self._upload("/v1/assets", image_path)
312
+
313
+ def import_asset(self, image_url):
314
+ """Ask the backend to import an allowed URL; the SDK never downloads it."""
315
+ if not isinstance(image_url, str) or not image_url.strip():
316
+ return self._failure(400, "image_url is required")
317
+ return self._request("POST", "/v1/assets/import", json={"imageUrl": image_url})
318
+
319
+ def get_asset(self, asset_id):
320
+ try:
321
+ self._identifier(asset_id)
322
+ except (ValueError, TypeError, AttributeError):
323
+ return self._failure(400, "Invalid asset_id")
324
+ return self._request("GET", "/v1/assets/" + asset_id)
325
+
326
+ def infer(self, model, asset_id, *, idempotency_key=None):
327
+ try:
328
+ if model not in self.MODELS:
329
+ raise ValueError("Supported models are mnet and icaa17k")
330
+ self._identifier(asset_id)
331
+ key = self._key(idempotency_key)
332
+ except (ValueError, TypeError, AttributeError) as error:
333
+ return self._failure(400, str(error))
334
+ return self._request(
335
+ "POST",
336
+ "/v1/inferences/" + model,
337
+ json={"assetId": asset_id},
338
+ context={"assetId": asset_id, "idempotencyKey": key, "model": model},
339
+ )
340
+
341
+ def get_inference(self, request_id):
342
+ try:
343
+ self._identifier(request_id)
344
+ except (ValueError, TypeError, AttributeError):
345
+ return self._failure(400, "Invalid request_id")
346
+ return self._request("GET", "/v1/inferences/" + request_id)
347
+
348
+ def _model(self, model, image_path, asset_id, key, legacy):
349
+ try:
350
+ key = self._key(key)
351
+ except ValueError as error:
352
+ return self._failure(400, str(error))
353
+ if (image_path is None) == (asset_id is None):
354
+ return self._failure(400, "Provide exactly one of image_path or asset_id")
355
+ if legacy:
356
+ if asset_id is not None:
357
+ return self._failure(400, "legacy=True requires an image file")
358
+ return self._file(model, image_path, idempotency_key=key)
359
+ if image_path is not None:
360
+ reply = self._upload(
361
+ "/v1/assets",
362
+ image_path,
363
+ context={"idempotencyKey": key, "model": model, "stage": "upload"},
364
+ )
365
+ if not self._ok(reply):
366
+ return reply
367
+ asset_id = (
368
+ reply.get("data", {}).get("assetId")
369
+ if isinstance(reply.get("data"), dict)
370
+ else None
371
+ )
372
+ if not asset_id:
373
+ return self._failure(
374
+ 502,
375
+ "Upload response omitted assetId",
376
+ context={"idempotencyKey": key},
377
+ error_type="protocol",
378
+ )
379
+ return self.infer(model, asset_id, idempotency_key=key)
380
+
381
+ def mnet(self, image_path=None, *, asset_id=None, idempotency_key=None, legacy=False):
382
+ return self._model("mnet", image_path, asset_id, idempotency_key, legacy)
383
+
384
+ def icaa17k(self, image_path=None, *, asset_id=None, idempotency_key=None, legacy=False):
385
+ return self._model("icaa17k", image_path, asset_id, idempotency_key, legacy)
386
+
387
+ def _file(
388
+ self, endpoint, file_path, *, fields=None, kind="image", binary=None, idempotency_key=None
389
+ ):
390
+ if self.token:
391
+ return self._failure(
392
+ 400,
393
+ "File compatibility APIs require API-Key credentials; use asset/inference or IQA task APIs for user tokens",
394
+ )
395
+ payload = {"accessKeyId": self.access_key_id, "sign": self.sign}
396
+ if fields:
397
+ payload.update(fields)
398
+ return self._upload(
399
+ "/api/file/" + endpoint,
400
+ file_path,
401
+ fields=payload,
402
+ kind=kind,
403
+ binary=binary,
404
+ legacy=True,
405
+ context={"idempotencyKey": idempotency_key} if idempotency_key else None,
406
+ )
407
+
408
+ # Existing names and server response fields remain intact.
409
+ def sharpness(self, image_path):
410
+ return self._file("sharpness", image_path)
411
+
412
+ def color(self, image_path):
413
+ return self._file("color", image_path)
414
+
415
+ def contrast(self, image_path):
416
+ return self._file("contrast", image_path)
417
+
418
+ def evaluate_all(self, image_path):
419
+ return self._file("evaluate_all", image_path)
420
+
421
+ def noise(self, image_path):
422
+ return self._file("noise", image_path)
423
+
424
+ def roc4mllm(self, image_path):
425
+ return self._file("roc4mllm", image_path)
426
+
427
+ def physique(self, image_path):
428
+ return self._file("physique", image_path)
429
+
430
+ def human(self, image_path):
431
+ return self._file("human", image_path)
432
+
433
+ def exposure(self, image_path):
434
+ return self._file("exposure", image_path)
435
+
436
+ def holistic(self, image_path):
437
+ return self._file("holistic", image_path)
438
+
439
+ def overall_iqa(self, image_path):
440
+ return self._file("overall_iqa", image_path)
441
+
442
+ def paa(self, image_path):
443
+ return self._file("paa", image_path)
444
+
445
+ def creative_crop(self, image_path):
446
+ return self._file("creative-crop", image_path, binary="image")
447
+
448
+ def highlight(self, video_path, *, query=""):
449
+ return self._file(
450
+ "highlight", video_path, fields={"query": query}, kind="video", binary="video"
451
+ )
452
+
453
+ def videomamba(self, video_path, *, query=""):
454
+ return self._file("videomamba", video_path, fields={"query": query}, kind="video")
455
+
456
+ def acellm(self, image_path, *, selections=("exposure",)):
457
+ if (
458
+ not isinstance(selections, (list, tuple))
459
+ or not selections
460
+ or any(not isinstance(item, str) for item in selections)
461
+ ):
462
+ return self._failure(400, "selections must be a non-empty list of metric names")
463
+ return self._file(
464
+ "acellm", image_path, fields={"selections": list(selections)}, binary="image"
465
+ )
466
+
467
+ def iqa_health(self):
468
+ return self._request("GET", "/v1/iqa/health")
469
+
470
+ def get_iqa_config(self):
471
+ return self._request("GET", "/v1/iqa/config")
472
+
473
+ def configure_iqa(self, config):
474
+ if not isinstance(config, dict):
475
+ return self._failure(400, "config must be an object")
476
+ return self._request("PUT", "/v1/iqa/config", json=config)
477
+
478
+ def upload_iqa_asset(self, file_path, *, kind="image"):
479
+ if kind not in ("image", "video"):
480
+ return self._failure(400, "kind must be image or video")
481
+ return self._upload("/v1/iqa/assets", file_path, kind=kind, fields={"kind": kind})
482
+
483
+ def create_iqa_task(
484
+ self, asset_ids, *, selections=DEFAULT_IQA_METRICS, kind="image", idempotency_key=None
485
+ ):
486
+ try:
487
+ if (
488
+ kind not in ("image", "video")
489
+ or not isinstance(asset_ids, (list, tuple))
490
+ or not 1 <= len(asset_ids) <= (1 if kind == "video" else 50)
491
+ ):
492
+ raise ValueError("Provide 1-50 image asset IDs or one video asset ID")
493
+ if len(set(asset_ids)) != len(asset_ids):
494
+ raise ValueError("asset_ids must not contain duplicates")
495
+ for asset_id in asset_ids:
496
+ self._identifier(asset_id)
497
+ if (
498
+ not isinstance(selections, (list, tuple))
499
+ or not selections
500
+ or len(set(selections)) != len(selections)
501
+ or not self.IQA_METRICS.issuperset(selections)
502
+ ):
503
+ raise ValueError("Invalid IQA selections")
504
+ base = self.IQA_METRICS - {"holistic", "overall_iqa"}
505
+ if "holistic" in selections and not base.intersection(selections):
506
+ raise ValueError("holistic requires at least one base metric")
507
+ if kind == "video" and "overall_iqa" in selections:
508
+ raise ValueError("overall_iqa is not available for video tasks")
509
+ key = self._key(idempotency_key)
510
+ except (ValueError, TypeError, AttributeError) as error:
511
+ return self._failure(400, str(error))
512
+ return self._request(
513
+ "POST",
514
+ "/v1/iqa/tasks",
515
+ json={"assetIds": list(asset_ids), "selections": list(selections), "kind": kind},
516
+ context={"idempotencyKey": key, "assetIds": list(asset_ids)},
517
+ )
518
+
519
+ def list_iqa_tasks(self):
520
+ return self._request("GET", "/v1/iqa/tasks")
521
+
522
+ def _iqa_task(self, task_id, cancel=False, timeout=None):
523
+ try:
524
+ self._identifier(task_id)
525
+ except (ValueError, TypeError, AttributeError):
526
+ return self._failure(400, "Invalid task_id")
527
+ return self._request(
528
+ "POST" if cancel else "GET",
529
+ "/v1/iqa/tasks/" + task_id + ("/cancel" if cancel else ""),
530
+ context={"taskId": task_id},
531
+ timeout=timeout,
532
+ )
533
+
534
+ def get_iqa_task(self, task_id):
535
+ return self._iqa_task(task_id)
536
+
537
+ def cancel_iqa_task(self, task_id):
538
+ return self._iqa_task(task_id, cancel=True)
539
+
540
+ def wait_iqa_task(self, task_id, *, wait_timeout=300, poll_interval=1):
541
+ if not self._positive(wait_timeout) or not self._positive(poll_interval):
542
+ return self._failure(400, "wait_timeout and poll_interval must be positive seconds")
543
+ deadline = time.monotonic() + wait_timeout
544
+ while True:
545
+ remaining = deadline - time.monotonic()
546
+ if remaining <= 0:
547
+ return self._failure(
548
+ 408,
549
+ "Stopped waiting; the server task has NOT been cancelled",
550
+ context={"taskId": task_id},
551
+ error_type="wait_timeout",
552
+ retryable=True,
553
+ )
554
+ reply = self._iqa_task(task_id, timeout=min(self.timeout, remaining))
555
+ if not self._ok(reply):
556
+ return reply
557
+ task = reply.get("data")
558
+ status = task.get("status") if isinstance(task, dict) else None
559
+ if status in self.TERMINAL_TASKS:
560
+ if status not in ("SUCCEEDED", "PARTIAL"):
561
+ return self._failure(
562
+ 409 if status == "CANCELLED" else 500,
563
+ task.get("message") or "IQA task {}".format(status),
564
+ context={"taskId": task_id},
565
+ error_type="task",
566
+ task=task,
567
+ )
568
+ return reply
569
+ if status not in self.PENDING_TASKS:
570
+ return self._failure(
571
+ 502,
572
+ "Unknown IQA task status",
573
+ context={"taskId": task_id},
574
+ error_type="protocol",
575
+ )
576
+ time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))
577
+
578
+ def iqa(
579
+ self,
580
+ file_paths,
581
+ *,
582
+ selections=DEFAULT_IQA_METRICS,
583
+ kind="image",
584
+ wait=True,
585
+ idempotency_key=None,
586
+ wait_timeout=300,
587
+ poll_interval=1,
588
+ ):
589
+ paths = [file_paths] if isinstance(file_paths, (str, Path)) else file_paths
590
+ if not isinstance(paths, (list, tuple)) or not 1 <= len(paths) <= (
591
+ 1 if kind == "video" else 50
592
+ ):
593
+ return self._failure(400, "Provide 1-50 image paths or one video path")
594
+ # Validate before uploading: a bad selection or missing second file must
595
+ # not leave avoidable partial uploads in the user's private storage.
596
+ try:
597
+ key = self._key(idempotency_key)
598
+ if (
599
+ kind not in ("image", "video")
600
+ or not isinstance(selections, (list, tuple))
601
+ or not selections
602
+ or len(set(selections)) != len(selections)
603
+ or not self.IQA_METRICS.issuperset(selections)
604
+ ):
605
+ raise ValueError("Invalid IQA selections or media kind")
606
+ if "holistic" in selections and not (
607
+ self.IQA_METRICS - {"holistic", "overall_iqa"}
608
+ ).intersection(selections):
609
+ raise ValueError("holistic requires at least one base metric")
610
+ if kind == "video" and "overall_iqa" in selections:
611
+ raise ValueError("overall_iqa is not available for video tasks")
612
+ if wait and (not self._positive(wait_timeout) or not self._positive(poll_interval)):
613
+ raise ValueError("wait_timeout and poll_interval must be positive seconds")
614
+ for path in paths:
615
+ file = Path(path)
616
+ if (
617
+ not file.is_file()
618
+ or not 0 < file.stat().st_size <= (100 if kind == "video" else 20) * 1024 * 1024
619
+ ):
620
+ raise ValueError("Input file is missing, empty or too large")
621
+ except (ValueError, TypeError, OSError) as error:
622
+ return self._failure(400, str(error))
623
+ assets = []
624
+ for path in paths:
625
+ uploaded = self._upload(
626
+ "/v1/iqa/assets",
627
+ path,
628
+ kind=kind,
629
+ fields={"kind": kind},
630
+ context={"idempotencyKey": key, "assetIds": list(assets), "stage": "upload"},
631
+ )
632
+ if not self._ok(uploaded):
633
+ return uploaded
634
+ asset_id = (
635
+ uploaded.get("data", {}).get("assetId")
636
+ if isinstance(uploaded.get("data"), dict)
637
+ else None
638
+ )
639
+ if not asset_id:
640
+ return self._failure(502, "Upload response omitted assetId", error_type="protocol")
641
+ assets.append(asset_id)
642
+ created = self.create_iqa_task(
643
+ assets, selections=selections, kind=kind, idempotency_key=key
644
+ )
645
+ if not self._ok(created) or not wait:
646
+ return created
647
+ task_id = (
648
+ created.get("data", {}).get("taskId") if isinstance(created.get("data"), dict) else None
649
+ )
650
+ if not task_id:
651
+ return self._failure(
652
+ 502,
653
+ "Task response omitted taskId",
654
+ error_type="protocol",
655
+ context=created.get("meta"),
656
+ )
657
+ try:
658
+ result = self.wait_iqa_task(
659
+ task_id, wait_timeout=wait_timeout, poll_interval=poll_interval
660
+ )
661
+ except AceError as error:
662
+ error.response["meta"] = {**created.get("meta", {}), **error.response.get("meta", {})}
663
+ raise AceError(error.response) from None
664
+ result["meta"] = {**created.get("meta", {}), **result.get("meta", {})}
665
+ return result
666
+
667
+ def close(self):
668
+ if not self._closed:
669
+ self.session.close()
670
+ self._closed = True
671
+
672
+ def __enter__(self):
673
+ return self
674
+
675
+ def __exit__(self, exc_type, exc, traceback):
676
+ self.close()
ace_engine/results.py ADDED
@@ -0,0 +1,32 @@
1
+ """Error details and binary output helpers; no credentials are logged."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ class AceError(RuntimeError):
9
+ """Raised only when ``raise_for_errors=True``; inspect ``response`` to recover."""
10
+
11
+ def __init__(self, response):
12
+ self.response = response
13
+ self.code = str(response.get("code", "500"))
14
+ self.http_status = response.get("httpStatus")
15
+ self.request_id = response.get("requestId") or response.get("meta", {}).get("requestId")
16
+ self.task_id = response.get("meta", {}).get("taskId")
17
+ self.idempotency_key = response.get("meta", {}).get("idempotencyKey")
18
+ super().__init__("ACE {}: {}".format(self.code, response.get("message", "Request failed")))
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BinaryResult:
23
+ content: bytes
24
+ content_type: str
25
+ request_id: Optional[str] = None
26
+
27
+ def save(self, path, *, overwrite=False):
28
+ """Save only on explicit request. Existing files are protected by default."""
29
+ target = Path(path)
30
+ with target.open("wb" if overwrite else "xb") as output:
31
+ output.write(self.content)
32
+ return target
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.4
2
+ Name: ace-engine-sdk
3
+ Version: 0.2.0
4
+ Summary: AceEngine image quality evaluation SDK
5
+ Home-page: https://github.com/YuqiPlus/ACEngine-Client
6
+ Author: YuqiPlus
7
+ Author-email: silverwjq@163.com
8
+ License-Expression: MIT
9
+ Project-URL: Documentation, https://github.com/YuqiPlus/ACEngine-Client#readme
10
+ Project-URL: Source, https://github.com/YuqiPlus/ACEngine-Client
11
+ Project-URL: Issues, https://github.com/YuqiPlus/ACEngine-Client/issues
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: requests>=2.25.0
18
+ Dynamic: author
19
+ Dynamic: author-email
20
+ Dynamic: classifier
21
+ Dynamic: description
22
+ Dynamic: description-content-type
23
+ Dynamic: home-page
24
+ Dynamic: license-file
25
+ Dynamic: license-expression
26
+ Dynamic: project-url
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
30
+
31
+ # ACEngine Client SDK
32
+
33
+ 用于调用 ACE 美学计算服务的 Python SDK。支持 MNet / ICAA17K 的私有资源调用、IQA 图片/视频异步任务,以及现有单项评分和图像/视频处理接口。
34
+
35
+ 本文档对应 **SDK 0.2.0**,需要 Python 3.9 或更新版本。发行状态以 PyPI 和 GitHub 的实际版本为准;尚未发布的改动请使用本地源码安装。维护者首次发布步骤见 [PUBLISHING.md](PUBLISHING.md)。
36
+
37
+ ## 安装
38
+
39
+ 发布到 PyPI 后,用户可以直接安装:
40
+
41
+ ```bash
42
+ pip install ace-engine-sdk
43
+ ```
44
+
45
+ ```bash
46
+ # GitHub 主分支
47
+ pip install --upgrade git+https://github.com/YuqiPlus/ACEngine-Client.git
48
+
49
+ # 本地开发
50
+ pip install -e .
51
+
52
+ # 已有的 0.1.0 标签(不含本轮新功能)
53
+ pip install git+https://github.com/YuqiPlus/ACEngine-Client.git@v0.1.0
54
+ ```
55
+
56
+ HTTPS 不通时可以使用 SSH(需要已经配置 GitHub SSH 密钥):
57
+
58
+ ```bash
59
+ pip install git+ssh://git@github.com/YuqiPlus/ACEngine-Client.git
60
+ ```
61
+
62
+ 代理、证书和网络连接由本机配置,不在 SDK 中固定代理地址,也不关闭 HTTPS 证书验证。卸载包名为 `ace-engine-sdk`:
63
+
64
+ ```bash
65
+ pip show ace-engine-sdk
66
+ pip uninstall ace-engine-sdk
67
+ ```
68
+
69
+ ## 快速开始
70
+
71
+ 密钥放在环境变量里,勿写进共享代码。示例占位符需要替换成控制台颁发的有效凭证:
72
+
73
+ ```bash
74
+ export ACE_ENGINE_ACCESS_KEY_ID="your-key-id"
75
+ export ACE_ENGINE_ACCESS_KEY_SECRET="your-key-secret"
76
+ ```
77
+
78
+ Windows PowerShell:
79
+
80
+ ```powershell
81
+ $env:ACE_ENGINE_ACCESS_KEY_ID="your-key-id"
82
+ $env:ACE_ENGINE_ACCESS_KEY_SECRET="your-key-secret"
83
+ ```
84
+
85
+ ```python
86
+ import os
87
+ from ace_engine import AceClient
88
+
89
+ with AceClient(
90
+ access_key_id=os.environ["ACE_ENGINE_ACCESS_KEY_ID"],
91
+ access_key_secret=os.environ["ACE_ENGINE_ACCESS_KEY_SECRET"],
92
+ raise_for_errors=True,
93
+ ) as client:
94
+ response = client.mnet("photo.png")
95
+ print(response["data"]) # 保留后端原始模型字段
96
+ print(response["meta"]) # requestId、assetId、costCents、durationMs、replayed 等
97
+ ```
98
+
99
+ 默认服务为 `https://ace-aesthetics.cn`,接口前缀为 `/ibt`。`base_url` 也接受 `https://ace-aesthetics.cn/ibt`,不会重复拼接前缀。自建网关可以配置 `base_url` 和 `api_prefix`;只有直接连接不带统一前缀的网关时才使用 `api_prefix=""`。
100
+
101
+ 旧的三参数构造 `AceClient(base_url, access_key_id, sign)` 继续可用,`sign` 是 `access_key_secret` 的兼容名称。两者同时传入时必须一致。`from ace_engine import Client` 也是同一客户端的别名。
102
+
103
+ ## 上传一次,调用多个模型
104
+
105
+ 下面使用 `client` 的片段应放在上面的 `with AceClient(...) as client:` 代码块内;离开代码块后连接已关闭。
106
+
107
+ ```python
108
+ uploaded = client.upload_asset("photo.png")
109
+ asset_id = uploaded["data"]["assetId"]
110
+
111
+ mnet_key = client.new_idempotency_key()
112
+ response = client.mnet(asset_id=asset_id, idempotency_key=mnet_key)
113
+
114
+ # 新的模型调用意图使用新的幂等键
115
+ icaa_key = client.new_idempotency_key()
116
+ response = client.icaa17k(asset_id=asset_id, idempotency_key=icaa_key)
117
+
118
+ # 检查资源 / 查询已经提交的调用,不重新执行模型
119
+ asset = client.get_asset(asset_id)
120
+ status = client.get_inference(response["meta"]["requestId"])
121
+ ```
122
+
123
+ `client.infer("mnet", asset_id, idempotency_key=...)` 和 `client.infer("icaa17k", ...)` 提供相同的底层入口。`mnet("photo.png")`、`icaa17k("photo.png")` 自动先上传再调用。
124
+
125
+ `import_asset(image_url)` 将网址交给服务端的允许来源检查;SDK 不下载任意网址,也不绕过后端来源限制。资源属于凭证对应的用户/团队,会按服务端保留策略过期;不能假定资源永久有效。
126
+
127
+ ### 幂等、错误和计费
128
+
129
+ 一次逻辑调用使用一个幂等键,网络超时或响应丢失后的恢复必须沿用原键。不同模型或不同图片使用新键。
130
+
131
+ ```python
132
+ from ace_engine import AceError
133
+
134
+ key = client.new_idempotency_key() # 调用前由业务持久化保存 key 和 asset_id
135
+ try:
136
+ response = client.infer("mnet", asset_id, idempotency_key=key)
137
+ except AceError as error:
138
+ print(error.code, error.request_id, error.idempotency_key)
139
+ # 有 request_id 时先 get_inference(request_id) 查看 RUNNING / SETTLING / SUCCEEDED / FAILED。
140
+ # 状态允许恢复时,再使用同一个模型、asset_id、key 调用 infer。
141
+ # 不要因超时直接换一个新 key。
142
+ ```
143
+
144
+ SDK **不自动重试模型 POST,不在错误后自动换旧接口重发**。MNet / ICAA 的服务端负责幂等、结算和余额检查;SDK 透传其 `costCents` 等实际字段,不估算费用、不伪造结果。`replayed=True` 表示服务端返回此前调用的结果,不是一次新的计费意图。
145
+
146
+ 默认 `raise_for_errors=False`,兼容 0.1 的字典返回方式,调用方应检查 `code` / `success`。HTTP 和业务错误保留原始业务码、说明、请求编号和 HTTP 状态(`httpStatus`)。本地或网络错误使用 `errorType` 区分。
147
+
148
+ 网络/协议异常可能带 `outcomeUnknown=True`:这表示客户端不能确认提交结果,不能据此推断模型未运行或未扣款。错误中的 `meta` 保留可用的 `assetId`、`idempotencyKey`、`taskId` 等恢复信息。密钥和令牌会从 SDK 返回的错误中脱敏;SDK 不打印请求或凭证,不跟随重定向发送凭证。
149
+
150
+ `raise_for_errors=True` 会抛出 `AceError`,其 `.response` 保留相同的错误字典。响应数据及费用字段以实际部署版本为准。
151
+
152
+ ## IQA 异步评估
153
+
154
+ ```python
155
+ # 一张或多张图片;默认等待完成
156
+ response = client.iqa(
157
+ ["photo1.png", "photo2.jpg"],
158
+ selections=["sharpness", "exposure", "contrast", "noise"],
159
+ wait_timeout=300,
160
+ )
161
+ print(response["data"]["status"])
162
+ print(response["data"].get("result"))
163
+ ```
164
+
165
+ 图片每任务最多 50 张,视频每任务 1 个。图片单文件上限 20 MiB,视频 100 MiB;还受服务端实际配额限制。支持的指标为:`sharpness`、`color_accuracy`、`saturation`、`exposure`、`contrast`、`noise`、`holistic`、`overall_iqa`。`holistic` 需要至少一项基础指标;视频不支持 `overall_iqa`。
166
+
167
+ 异步提交、查询和取消:
168
+
169
+ ```python
170
+ asset = client.upload_iqa_asset("clip.mp4", kind="video")
171
+ asset_ids = [asset["data"]["assetId"]]
172
+ key = client.new_idempotency_key()
173
+ created = client.create_iqa_task(
174
+ asset_ids, kind="video", selections=["exposure"], idempotency_key=key,
175
+ )
176
+ task_id = created["data"]["taskId"]
177
+
178
+ response = client.get_iqa_task(task_id)
179
+ response = client.wait_iqa_task(task_id, wait_timeout=600, poll_interval=2)
180
+ # client.cancel_iqa_task(task_id) # 只有显式调用才请求取消
181
+ # client.list_iqa_tasks()
182
+ ```
183
+
184
+ 也可以 `client.iqa("photo.png", selections=["exposure"], wait=False)`,返回已创建的任务。`wait_timeout` 只限制客户端等待,超时不会自动取消、重建或重新扣费;保存错误 `meta.taskId` 后继续查询。
185
+
186
+ `SUCCEEDED` 是完成,`PARTIAL` 表示部分结果可用(需要检查任务里的 errors),`FAILED` / `CANCELLED` 在等待接口中作为错误返回。查询接口保留服务端原始状态。等待网络中断后继续查询原任务,不要重新上传并创建另一个任务。若创建响应丢失,应使用原 `asset_ids`、指标、kind 和幂等键恢复;IQA 重传文件会产生新资源 ID,不能假定与原任务等价。
187
+
188
+ MNet/ICAA 的资源与 IQA 的资源是不同接口管理的资源,不能混用 ID。IQA 输出可能包含短期签名图片/CSV/视频链接;保留签名参数,失效后重新查询任务,SDK 不向这些链接转发账户凭证。
189
+
190
+ `get_iqa_config()` 读取配置;`configure_iqa(config)` 显式提交服务端要求的完整权重/阈值对象。`iqa()` 不会暗中修改账户默认设置,也不支持旧网页中曾出现但服务端并无对应参数的 `weights={...}` 用法。
191
+
192
+ ## 兼容的单项文件接口
193
+
194
+ 以下方法接受本地图片,继续调用对应的 `/api/file/...` 接口:
195
+
196
+ | 方法 | 用途 |
197
+ | --- | --- |
198
+ | `sharpness(path)` | 清晰度 |
199
+ | `color(path)` | 色彩 |
200
+ | `contrast(path)` | 对比度 |
201
+ | `noise(path)` | 噪声 |
202
+ | `exposure(path)` | 曝光 |
203
+ | `holistic(path)` | 综合质量 |
204
+ | `evaluate_all(path)` | 服务端定义的综合评估 |
205
+ | `overall_iqa(path)` | 高分辨率整体质量 |
206
+ | `roc4mllm(path)` | 多模态评价 |
207
+ | `physique(path)` / `paa(path)` | 体态评价 |
208
+ | `human(path)` | 人像评价 |
209
+
210
+ 这些接口使用 API-Key 表单鉴权,保留服务端各自的数据字段,不统一捏造成 `{score, level}`。实际可用性取决于部署的模型服务、凭证权限和余额。
211
+
212
+ MNet / ICAA 默认走新资源接口;只有明确连接旧版服务时才使用 `client.mnet(path, legacy=True)` 或 `client.icaa17k(path, legacy=True)`。普通单项文件接口没有统一幂等保障,超时后不能盲目重发;SDK 不自动重试。
213
+
214
+ ## 图像和视频输出
215
+
216
+ ```python
217
+ from ace_engine import BinaryResult
218
+
219
+ result = client.creative_crop("photo.jpg")
220
+ if isinstance(result, BinaryResult):
221
+ result.save("crop.jpg") # 默认拒绝覆盖已有文件
222
+
223
+ # 返回 BinaryResult,保留真实 Content-Type
224
+ result = client.acellm("photo.jpg", selections=["exposure"])
225
+ result = client.highlight("clip.mp4", query="a person running")
226
+
227
+ # JSON 响应
228
+ result = client.videomamba("clip.mp4", query="a person running")
229
+ ```
230
+
231
+ 二进制成功返回 `BinaryResult(content, content_type, request_id)`;失败仍按统一错误机制处理,不把 JSON 报错写成图片或视频。内容在内存中,只有显式 `.save()` 才写文件;覆盖需要 `overwrite=True`。
232
+
233
+ ## 网站登录令牌
234
+
235
+ 已有有效普通用户 JWT 时,可使用 `AceClient(token=token)` 访问新资源、模型调用和正式 IQA 任务接口。SDK 不负责登录、验证码或刷新令牌。API-Key 与 JWT 必须选择一种,不能混传;旧的单项文件接口需要 API-Key,不自动切换鉴权身份。
236
+
237
+ ## 命令行示例与验证
238
+
239
+ ```bash
240
+ python test_my_sdk.py photo.png --model mnet
241
+ python test_my_sdk.py photo.png --model icaa17k --idempotency-key your-persisted-key
242
+ python test_my_sdk.py photo.png --model iqa --selections exposure noise
243
+
244
+ python -m unittest discover -s tests -v
245
+ python -m build
246
+ ```
247
+
248
+ `ACE_ENGINE_BASE_URL`、`ACE_ENGINE_API_PREFIX`、`ACE_ENGINE_ACCESS_KEY_ID`、`ACE_ENGINE_ACCESS_KEY_SECRET`(兼容 `ACE_ENGINE_SIGN`)及 `ACE_ENGINE_TOKEN` 可供示例程序使用。测试使用隔离的本机 HTTP 服务,不依赖真实密钥、不执行模型、不产生费用。实际联调需另用测试账号核对模型、状态和账单,不能用传输测试代替真实验证。
@@ -0,0 +1,8 @@
1
+ ace_engine/__init__.py,sha256=a5m-azvaDT7qUcmgP0eRTC5RG7XycvlW6HZJF97PIkY,193
2
+ ace_engine/client.py,sha256=xWQIVz_66yQ5P5HdpAhBc4eNeYa1KEt7VOeilZSEUcY,26503
3
+ ace_engine/results.py,sha256=lPUrVw-LMDrPVkNkKNhGu7sopw8r4H1nLkl7zQ2cJGM,1204
4
+ ace_engine_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=OvNOOjocN-n1WCW2_siK2OJfa3Q7e4aTdVSWXBCLrbM,1059
5
+ ace_engine_sdk-0.2.0.dist-info/METADATA,sha256=Kgo7Kmjw4FW222GVujmJ9NjcuZnp3UWt1NXW2YHK01w,11571
6
+ ace_engine_sdk-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ ace_engine_sdk-0.2.0.dist-info/top_level.txt,sha256=dEM0eD9JZwrERUWhi_9U6w3PRGswZ6k80hch2nkcd0M,11
8
+ ace_engine_sdk-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ACE
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ace_engine