authentasdk 0.2.8__tar.gz → 0.2.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authentasdk
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Python SDK for the Authenta API to detect deepfakes and manipulated media
5
5
  Home-page: https://github.com/phospheneai/authenta-python-sdk
6
6
  Author: Arjun Adhithya
@@ -32,7 +32,8 @@ Welcome to the official documentation for the **Authenta Python SDK** — your g
32
32
  - [4.3 FI-1 — Face Intelligence](#43-fi-1--face-intelligence)
33
33
  - [4.4 FE-1 — Face Embedding](#44-fe-1--face-embedding)
34
34
  - [4.5 DI-1 — Document Forgery Detection](#45-di-1--document-forgery-detection)
35
- - [4.6 Media Management](#46-media-management)
35
+ - [4.6 Face Indexing](#46-face-indexing)
36
+ - [4.7 Media Management](#47-media-management)
36
37
  5. [Visualization](#5-visualization)
37
38
  6. [Error Handling](#6-error-handling)
38
39
  7. [API Reference](#7-api-reference)
@@ -650,7 +651,89 @@ asyncio.run(detect_document())
650
651
 
651
652
  ---
652
653
 
653
- ### 4.6 Media Management
654
+ ### 4.6 Face Indexing
655
+
656
+ Index a person's photos, then find that face again later. This is separate from
657
+ the detection models — it runs no FI-1/DF-1/AC-1 job, and uses three endpoints
658
+ on the same host and API key:
659
+
660
+ | Method | Endpoint |
661
+ |---|---|
662
+ | `faceEnroll(images)` | `POST /api/v1/facesim/v1/enroll` |
663
+ | `tenants()` | `GET /api/v1/facesim/v1/subjects` |
664
+ | `faceSearch(image, limit)` | `POST /api/v1/facesim/v1/search` |
665
+
666
+ #### Enrol a face
667
+
668
+ ```python
669
+ enrolled = client.faceEnroll([
670
+ "data_samples/face_similiar/person_1/A.jpeg",
671
+ "data_samples/face_similiar/person_1/B.jpeg",
672
+ ])
673
+
674
+ print(enrolled["subject_id"])
675
+ for face in enrolled["faces"]:
676
+ print(face["face_id"], face["status"]) # "uploaded" or "failed"
677
+ ```
678
+
679
+ Accepts **1–10 images** of the same person, and only **JPEG, PNG, or WebP**.
680
+ Every file is validated before anything is sent, so a bad path never leaves a
681
+ half-built subject on the server.
682
+
683
+ `faceEnroll` returns as soon as the photos reach S3. The embeddings are
684
+ generated in the background, so each face comes back as `uploaded`, not
685
+ `processed` — poll `tenants()` to see them settle. One failed upload marks only
686
+ its own face rather than sinking the batch.
687
+
688
+ #### Check indexing progress
689
+
690
+ ```python
691
+ for subject in client.tenants()["subjects"]:
692
+ for face in subject["faces"]:
693
+ print(subject["subject_id"], face["name"], face["status"], face["error"])
694
+ ```
695
+
696
+ | Status | Meaning |
697
+ |---|---|
698
+ | `pending` | Upload URL created; the object has not been acknowledged yet |
699
+ | `uploaded` | Stored; waiting for a worker |
700
+ | `processing` | A worker is generating the embedding |
701
+ | `processed` | Embedding stored — the face is searchable |
702
+ | `failed` | Upload validation or embedding failed — see `error` |
703
+
704
+ Only `processed` faces are searched, so searching too early returns `count: 0`.
705
+
706
+ #### Search a face
707
+
708
+ ```python
709
+ matches = client.faceSearch("data_samples/face_similiar/person_1/A.jpeg", limit=10)
710
+
711
+ print(matches["count"])
712
+ for match in matches["results"]:
713
+ print(match["rank"], match["subject_id"], match["similarity_score"])
714
+ ```
715
+
716
+ The photo is posted as `multipart/form-data` rather than Base64 in a JSON body,
717
+ which keeps it clear of the server's 100 KiB JSON limit. `limit` is clamped to
718
+ 1–50 and defaults to 50.
719
+
720
+ Search is independent of enrolment: it matches everything already indexed on the
721
+ account, including from earlier sessions. The same subject can appear more than
722
+ once because every enrolled face has its own embedding, and results run from
723
+ highest similarity down.
724
+
725
+ Unsupported formats are converted to JPEG when **Pillow** is installed
726
+ (`pip install Pillow`); without it you get a clear `ValidationError` instead of
727
+ a server-side `invalid_image`. The conversion also bakes any EXIF rotation into
728
+ the pixels — an image left with a stale orientation tag is read sideways by face
729
+ detection and comes back as `no_face_detected`.
730
+
731
+ A full runnable script lives in
732
+ [`examples/test_face_indexing.py`](examples/test_face_indexing.py).
733
+
734
+ ---
735
+
736
+ ### 4.7 Media Management
654
737
 
655
738
  #### Get Media
656
739
 
@@ -960,6 +1043,40 @@ Raises `ValueError` if no result artifact is found.
960
1043
 
961
1044
  ---
962
1045
 
1046
+ #### `faceEnroll(images) -> Dict`
1047
+
1048
+ Enrol 1–10 photos of one person: creates the subject, then uploads each image
1049
+ to its presigned S3 URL.
1050
+
1051
+ | Parameter | Type | Description |
1052
+ |---|---|---|
1053
+ | `images` | `list[str]` | Local paths. JPEG, PNG, or WebP only |
1054
+
1055
+ Returns the enroll response — `subject_id`, `status`, and a `faces` array whose
1056
+ `status` is set to `uploaded` or `failed` per image. Embeddings follow out of
1057
+ band; poll `tenants()` for `processed`.
1058
+
1059
+ Raises `ValidationError` for an empty list, more than 10 images, a missing file,
1060
+ or an unsupported image type — all checked before any request is sent.
1061
+
1062
+ #### `tenants() -> Dict`
1063
+
1064
+ Every subject and face on the account: `{"tenant_id", "subjects"}`, where each
1065
+ subject holds `face_id`, `name`, `status`, `embedding`, `image_url`, and `error`.
1066
+
1067
+ #### `faceSearch(image, limit=50, timeout=120.0) -> Dict`
1068
+
1069
+ Rank enrolled faces against a query photo, posted as `multipart/form-data`.
1070
+
1071
+ | Parameter | Type | Default | Description |
1072
+ |---|---|---|---|
1073
+ | `image` | `str` | — | Local path to the query photo |
1074
+ | `limit` | `int` | `50` | Matches to return, clamped to 1–50 |
1075
+ | `timeout` | `float` | `120.0` | Seconds — embedding takes longer than a plain request |
1076
+
1077
+ Returns `{"tenant_id", "count", "results"}` with results ordered from highest
1078
+ similarity down. Only faces with `status: "processed"` are searched.
1079
+
963
1080
  #### `get_media(jobid) -> Dict`
964
1081
 
965
1082
  `GET /api/v1/jobs/{jobid}` — fetch the current state of a media record.
@@ -15,7 +15,8 @@ Welcome to the official documentation for the **Authenta Python SDK** — your g
15
15
  - [4.3 FI-1 — Face Intelligence](#43-fi-1--face-intelligence)
16
16
  - [4.4 FE-1 — Face Embedding](#44-fe-1--face-embedding)
17
17
  - [4.5 DI-1 — Document Forgery Detection](#45-di-1--document-forgery-detection)
18
- - [4.6 Media Management](#46-media-management)
18
+ - [4.6 Face Indexing](#46-face-indexing)
19
+ - [4.7 Media Management](#47-media-management)
19
20
  5. [Visualization](#5-visualization)
20
21
  6. [Error Handling](#6-error-handling)
21
22
  7. [API Reference](#7-api-reference)
@@ -633,7 +634,89 @@ asyncio.run(detect_document())
633
634
 
634
635
  ---
635
636
 
636
- ### 4.6 Media Management
637
+ ### 4.6 Face Indexing
638
+
639
+ Index a person's photos, then find that face again later. This is separate from
640
+ the detection models — it runs no FI-1/DF-1/AC-1 job, and uses three endpoints
641
+ on the same host and API key:
642
+
643
+ | Method | Endpoint |
644
+ |---|---|
645
+ | `faceEnroll(images)` | `POST /api/v1/facesim/v1/enroll` |
646
+ | `tenants()` | `GET /api/v1/facesim/v1/subjects` |
647
+ | `faceSearch(image, limit)` | `POST /api/v1/facesim/v1/search` |
648
+
649
+ #### Enrol a face
650
+
651
+ ```python
652
+ enrolled = client.faceEnroll([
653
+ "data_samples/face_similiar/person_1/A.jpeg",
654
+ "data_samples/face_similiar/person_1/B.jpeg",
655
+ ])
656
+
657
+ print(enrolled["subject_id"])
658
+ for face in enrolled["faces"]:
659
+ print(face["face_id"], face["status"]) # "uploaded" or "failed"
660
+ ```
661
+
662
+ Accepts **1–10 images** of the same person, and only **JPEG, PNG, or WebP**.
663
+ Every file is validated before anything is sent, so a bad path never leaves a
664
+ half-built subject on the server.
665
+
666
+ `faceEnroll` returns as soon as the photos reach S3. The embeddings are
667
+ generated in the background, so each face comes back as `uploaded`, not
668
+ `processed` — poll `tenants()` to see them settle. One failed upload marks only
669
+ its own face rather than sinking the batch.
670
+
671
+ #### Check indexing progress
672
+
673
+ ```python
674
+ for subject in client.tenants()["subjects"]:
675
+ for face in subject["faces"]:
676
+ print(subject["subject_id"], face["name"], face["status"], face["error"])
677
+ ```
678
+
679
+ | Status | Meaning |
680
+ |---|---|
681
+ | `pending` | Upload URL created; the object has not been acknowledged yet |
682
+ | `uploaded` | Stored; waiting for a worker |
683
+ | `processing` | A worker is generating the embedding |
684
+ | `processed` | Embedding stored — the face is searchable |
685
+ | `failed` | Upload validation or embedding failed — see `error` |
686
+
687
+ Only `processed` faces are searched, so searching too early returns `count: 0`.
688
+
689
+ #### Search a face
690
+
691
+ ```python
692
+ matches = client.faceSearch("data_samples/face_similiar/person_1/A.jpeg", limit=10)
693
+
694
+ print(matches["count"])
695
+ for match in matches["results"]:
696
+ print(match["rank"], match["subject_id"], match["similarity_score"])
697
+ ```
698
+
699
+ The photo is posted as `multipart/form-data` rather than Base64 in a JSON body,
700
+ which keeps it clear of the server's 100 KiB JSON limit. `limit` is clamped to
701
+ 1–50 and defaults to 50.
702
+
703
+ Search is independent of enrolment: it matches everything already indexed on the
704
+ account, including from earlier sessions. The same subject can appear more than
705
+ once because every enrolled face has its own embedding, and results run from
706
+ highest similarity down.
707
+
708
+ Unsupported formats are converted to JPEG when **Pillow** is installed
709
+ (`pip install Pillow`); without it you get a clear `ValidationError` instead of
710
+ a server-side `invalid_image`. The conversion also bakes any EXIF rotation into
711
+ the pixels — an image left with a stale orientation tag is read sideways by face
712
+ detection and comes back as `no_face_detected`.
713
+
714
+ A full runnable script lives in
715
+ [`examples/test_face_indexing.py`](examples/test_face_indexing.py).
716
+
717
+ ---
718
+
719
+ ### 4.7 Media Management
637
720
 
638
721
  #### Get Media
639
722
 
@@ -943,6 +1026,40 @@ Raises `ValueError` if no result artifact is found.
943
1026
 
944
1027
  ---
945
1028
 
1029
+ #### `faceEnroll(images) -> Dict`
1030
+
1031
+ Enrol 1–10 photos of one person: creates the subject, then uploads each image
1032
+ to its presigned S3 URL.
1033
+
1034
+ | Parameter | Type | Description |
1035
+ |---|---|---|
1036
+ | `images` | `list[str]` | Local paths. JPEG, PNG, or WebP only |
1037
+
1038
+ Returns the enroll response — `subject_id`, `status`, and a `faces` array whose
1039
+ `status` is set to `uploaded` or `failed` per image. Embeddings follow out of
1040
+ band; poll `tenants()` for `processed`.
1041
+
1042
+ Raises `ValidationError` for an empty list, more than 10 images, a missing file,
1043
+ or an unsupported image type — all checked before any request is sent.
1044
+
1045
+ #### `tenants() -> Dict`
1046
+
1047
+ Every subject and face on the account: `{"tenant_id", "subjects"}`, where each
1048
+ subject holds `face_id`, `name`, `status`, `embedding`, `image_url`, and `error`.
1049
+
1050
+ #### `faceSearch(image, limit=50, timeout=120.0) -> Dict`
1051
+
1052
+ Rank enrolled faces against a query photo, posted as `multipart/form-data`.
1053
+
1054
+ | Parameter | Type | Default | Description |
1055
+ |---|---|---|---|
1056
+ | `image` | `str` | — | Local path to the query photo |
1057
+ | `limit` | `int` | `50` | Matches to return, clamped to 1–50 |
1058
+ | `timeout` | `float` | `120.0` | Seconds — embedding takes longer than a plain request |
1059
+
1060
+ Returns `{"tenant_id", "count", "results"}` with results ordered from highest
1061
+ similarity down. Only faces with `status: "processed"` are searched.
1062
+
946
1063
  #### `get_media(jobid) -> Dict`
947
1064
 
948
1065
  `GET /api/v1/jobs/{jobid}` — fetch the current state of a media record.
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = authentasdk
3
- version = 0.2.8
3
+ version = 0.2.9
4
4
  description = Python SDK for the Authenta API to detect deepfakes and manipulated media
5
5
  author = Arjun Adhithya
6
6
  author_email = rrarjunadhithya@gmail.com
@@ -21,6 +21,12 @@ from .authenta_exceptions import (
21
21
  ValidationError,
22
22
  ServerError,
23
23
  )
24
+ from .face_auth import (
25
+ BASE as FACESIM_BASE,
26
+ MAX_SEARCH_LIMIT,
27
+ FaceAuth,
28
+ clamp_limit,
29
+ )
24
30
 
25
31
  def _raise_for_authenta_error(resp: requests.Response) -> None:
26
32
  """
@@ -101,18 +107,23 @@ class AuthentaClient:
101
107
  Create new Authenta client.
102
108
 
103
109
  Args:
104
- base_url: Authenta API base URL, e.g. "https://platform/.authenta.ai".
110
+ base_url: Authenta API base URL, e.g. "https://platform.authenta.ai".
105
111
  api_key: Your Authenta API key.
106
112
  """
107
113
  self.base_url = base_url.rstrip("/")
108
114
  self.api_key = api_key
109
115
  self.auth_enabled = bool(api_key)
110
116
 
111
- def _headers(self) -> Dict[str, str]:
112
- """Return default headers for Authenta API calls."""
113
- headers = {
114
- "Content-Type": "application/json",
115
- }
117
+ def _headers(self, content_type: Optional[str] = "application/json") -> Dict[str, str]:
118
+ """
119
+ Return default headers for Authenta API calls.
120
+
121
+ Pass ``content_type=None`` for multipart uploads — requests generates
122
+ the boundary itself, and setting the header by hand breaks it.
123
+ """
124
+ headers = {}
125
+ if content_type:
126
+ headers["Content-Type"] = content_type
116
127
  if self.auth_enabled:
117
128
  headers["Authorization"] = f"Bearer {self.api_key}"
118
129
 
@@ -376,6 +387,124 @@ class AuthentaClient:
376
387
 
377
388
  return media
378
389
 
390
+
391
+ # ── Face indexing ──────────────────────────────────────────────────────
392
+ # Three endpoints on this same host and API key. See face_auth.py.
393
+
394
+ def faceEnroll(self, images: list) -> Dict[str, Any]:
395
+ """
396
+ Enrol photos of one person: create the subject, then upload each image.
397
+
398
+ Returns as soon as S3 has the bytes. Embeddings are generated out of
399
+ band, so call :meth:`tenants` afterwards to watch each face reach
400
+ ``processed``.
401
+
402
+ Args:
403
+ images: Local paths to 1-10 photos of the same person. Only
404
+ JPEG, PNG, and WebP are accepted.
405
+
406
+ Returns:
407
+ The enroll response, with each face's ``status`` updated to
408
+ ``uploaded`` or ``failed`` to reflect its own upload outcome.
409
+ """
410
+ face_auth = FaceAuth()
411
+ described_images = face_auth.EnrollFace(images)
412
+
413
+ resp = requests.post(
414
+ f"{self.base_url}{FACESIM_BASE}/enroll",
415
+ json={"images": described_images},
416
+ headers=self._headers(),
417
+ timeout=30,
418
+ )
419
+ if not resp.ok:
420
+ _raise_for_authenta_error(resp)
421
+ enrollment = _safe_json(resp)
422
+
423
+ faces = enrollment.get("faces", [])
424
+ if len(faces) != len(images):
425
+ raise RuntimeError(
426
+ f"Enrollment returned {len(faces)} upload URLs for {len(images)} images"
427
+ )
428
+
429
+ # The response preserves the order of `images`. One failed PUT marks
430
+ # only its own face, rather than sinking the whole batch.
431
+ for face, image, described in zip(faces, images, described_images):
432
+ upload_url = face.get("upload_url")
433
+ if not upload_url:
434
+ face["status"] = "failed"
435
+ continue
436
+
437
+ content_type = (face.get("headers") or {}).get(
438
+ "Content-Type", described["contentType"]
439
+ )
440
+ print(f"Uploading {os.path.basename(image)}...")
441
+ status = face_auth.putSignedUrl(upload_url, image, content_type)
442
+ face["status"] = "uploaded" if 200 <= status < 300 else "failed"
443
+
444
+ return enrollment
445
+
446
+ def tenants(self) -> Dict[str, Any]:
447
+ """
448
+ GET /api/v1/facesim/v1/subjects: every subject and face on the account.
449
+
450
+ Use it to follow enrolment progress — a face is searchable once its
451
+ status reaches ``processed``.
452
+ """
453
+ resp = requests.get(
454
+ f"{self.base_url}{FACESIM_BASE}/tenant",
455
+ headers=self._headers(),
456
+ timeout=30,
457
+ )
458
+ if not resp.ok:
459
+ _raise_for_authenta_error(resp)
460
+ return _safe_json(resp)
461
+
462
+ def faceSearch(
463
+ self,
464
+ image: str,
465
+ limit: int = MAX_SEARCH_LIMIT,
466
+ timeout: float = 120.0,
467
+ ) -> Dict[str, Any]:
468
+ """
469
+ POST /api/v1/facesim/v1/search: rank enrolled faces against a photo.
470
+
471
+ The image is posted as ``multipart/form-data`` rather than Base64 in a
472
+ JSON body, which keeps it clear of the server's 100 KiB JSON limit.
473
+
474
+ Search is independent of enrolment: it matches everything already
475
+ indexed on the account, including from earlier sessions. The same
476
+ subject can appear more than once because every enrolled face has its
477
+ own embedding.
478
+
479
+ Args:
480
+ image: Local path to the query photo.
481
+ limit: How many matches to return, clamped to 1-50.
482
+ timeout: Seconds to wait — embedding a face takes longer than a
483
+ plain request.
484
+
485
+ Returns:
486
+ ``{"tenant_id", "count", "results"}`` with results ordered from
487
+ highest similarity down.
488
+ """
489
+ face_auth = FaceAuth()
490
+ path, content_type = face_auth.prepare_search_image(image)
491
+ capped = clamp_limit(limit)
492
+
493
+ print(f"Searching faces with {os.path.basename(path)} (limit {capped})...")
494
+ with open(path, "rb") as handle:
495
+ resp = requests.post(
496
+ f"{self.base_url}{FACESIM_BASE}/search",
497
+ # No Content-Type header here: requests sets the multipart
498
+ # boundary itself, and overriding it breaks the upload.
499
+ headers=self._headers(content_type=None),
500
+ files={"image": (os.path.basename(path), handle, content_type)},
501
+ data={"limit": str(capped)},
502
+ timeout=timeout,
503
+ )
504
+ if not resp.ok:
505
+ _raise_for_authenta_error(resp)
506
+ return _safe_json(resp)
507
+
379
508
  def get_result(self, media: Dict[str, Any]) -> Dict[str, Any]:
380
509
  """
381
510
  Fetch the detection result JSON from the media's resultURL.
@@ -0,0 +1,176 @@
1
+ """
2
+ Face indexing helpers — enrol faces, list subjects, search a face.
3
+
4
+ Mirrors ``packages/core/src/core/faceauth.ts`` in the React Native SDK. Three
5
+ endpoints, all on the same host and API key as the rest of the platform:
6
+
7
+ POST /api/v1/facesim/v1/enroll create a subject + presigned upload URLs
8
+ GET /api/v1/facesim/v1/subjects every subject and face
9
+ POST /api/v1/facesim/v1/search rank enrolled faces against a photo
10
+
11
+ The functions here do the local work — validating files, deriving names and
12
+ content types, uploading to S3. The HTTP calls live on
13
+ ``AuthentaClient.faceEnroll`` / ``faceSearch`` / ``tenants``.
14
+ """
15
+
16
+ import mimetypes
17
+ import os
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ import requests
21
+
22
+ from .authenta_exceptions import ValidationError
23
+
24
+ # ── Contract limits, matching the React Native SDK ──────────────────────────
25
+
26
+ BASE = "/api/v1/facesim/v1"
27
+
28
+ #: Content types the face indexing service can decode.
29
+ SUPPORTED_FACE_IMAGE_TYPES = ("image/jpeg", "image/png", "image/webp")
30
+
31
+ #: Enrollment accepts between 1 and 10 images per subject.
32
+ MIN_ENROLL_IMAGES = 1
33
+ MAX_ENROLL_IMAGES = 10
34
+
35
+ #: Search returns at most 50 ranked faces.
36
+ MAX_SEARCH_LIMIT = 50
37
+
38
+ #: A face has settled once it can no longer change.
39
+ TERMINAL_FACE_STATUSES = ("processed", "failed")
40
+
41
+
42
+ def guess_content_type(path: str) -> str:
43
+ """Guess the MIME type of a file from its name."""
44
+ content_type, _ = mimetypes.guess_type(path)
45
+ return (content_type or "application/octet-stream").lower()
46
+
47
+
48
+ def describe_image(image: str) -> Dict[str, str]:
49
+ """
50
+ Build the ``{"name", "contentType"}`` descriptor the enroll endpoint expects.
51
+
52
+ The full file name is kept — including the extension — because the server
53
+ uses it to label the face, and the content type is derived from it.
54
+
55
+ Raises:
56
+ ValidationError: if the file is missing, unnamed, or an unsupported type.
57
+ """
58
+ if not isinstance(image, str) or not image.strip():
59
+ raise ValidationError("Image path must be a non-empty string.")
60
+ if not os.path.isfile(image):
61
+ raise ValidationError(f"Image file does not exist: {image}")
62
+
63
+ name = os.path.basename(image).strip()
64
+ if not name or len(name) > 255:
65
+ raise ValidationError(f'Image name must be 1-255 characters - received: "{name}"')
66
+
67
+ content_type = guess_content_type(name)
68
+ if content_type not in SUPPORTED_FACE_IMAGE_TYPES:
69
+ raise ValidationError(
70
+ f'Unsupported image type "{content_type}" for {name}. '
71
+ f"Face indexing accepts {', '.join(SUPPORTED_FACE_IMAGE_TYPES)}."
72
+ )
73
+
74
+ return {"name": name, "contentType": content_type}
75
+
76
+
77
+ def image_basename(image: str) -> Dict[str, str]:
78
+ """Backwards-compatible alias of :func:`describe_image`."""
79
+ return describe_image(image)
80
+
81
+
82
+ def clamp_limit(limit: Optional[int]) -> int:
83
+ """Clamp a search limit into the 1-50 range the API accepts."""
84
+ try:
85
+ value = int(limit)
86
+ except (TypeError, ValueError):
87
+ value = MAX_SEARCH_LIMIT
88
+ return max(1, min(value, MAX_SEARCH_LIMIT))
89
+
90
+
91
+ class FaceAuth:
92
+ """Local file handling for the face indexing endpoints."""
93
+
94
+ def EnrollFace(self, images: List[str]) -> List[Dict[str, str]]:
95
+ """
96
+ Validate the images and build the ``images`` array for ``POST /enroll``.
97
+
98
+ Every file is checked before anything is sent, so a bad path fails
99
+ before it can leave a half-built subject on the server.
100
+
101
+ Args:
102
+ images: Local paths to 1-10 photos of the same person.
103
+
104
+ Returns:
105
+ One ``{"name", "contentType"}`` descriptor per image, in order.
106
+ """
107
+ if not isinstance(images, (list, tuple)):
108
+ raise ValidationError("images must be a list of file paths.")
109
+ if len(images) < MIN_ENROLL_IMAGES or len(images) > MAX_ENROLL_IMAGES:
110
+ raise ValidationError(
111
+ f"Enrollment accepts {MIN_ENROLL_IMAGES}-{MAX_ENROLL_IMAGES} images "
112
+ f"- received {len(images)}."
113
+ )
114
+
115
+ return [describe_image(image) for image in images]
116
+
117
+ def putSignedUrl(
118
+ self,
119
+ url: str,
120
+ image: str,
121
+ content_type: Optional[str] = None,
122
+ ) -> int:
123
+ """
124
+ PUT one image to its presigned S3 URL.
125
+
126
+ The ``Content-Type`` must match the one the enroll response returned, or
127
+ S3 rejects the signature with 403.
128
+
129
+ Returns:
130
+ The HTTP status code from S3.
131
+ """
132
+ headers = {"Content-Type": content_type or guess_content_type(image)}
133
+ with open(image, "rb") as handle:
134
+ response = requests.put(url, data=handle, headers=headers, timeout=300)
135
+ return response.status_code
136
+
137
+ def prepare_search_image(self, image: str) -> Tuple[str, str]:
138
+ """
139
+ Check a query photo and report how to send it.
140
+
141
+ Unsupported formats (HEIC, TIFF, ...) are converted to JPEG when Pillow
142
+ is installed; without Pillow the caller gets a clear error instead of a
143
+ server-side ``invalid_image``.
144
+
145
+ Returns:
146
+ ``(path, content_type)`` ready for a multipart upload.
147
+ """
148
+ if not isinstance(image, str) or not image.strip():
149
+ raise ValidationError("A query image is required to search faces.")
150
+ if not os.path.isfile(image):
151
+ raise ValidationError(f"Image file does not exist: {image}")
152
+
153
+ content_type = guess_content_type(image)
154
+ if content_type in SUPPORTED_FACE_IMAGE_TYPES:
155
+ return image, content_type
156
+
157
+ try:
158
+ from PIL import Image # optional dependency
159
+ except ImportError:
160
+ raise ValidationError(
161
+ f'Unsupported image type "{content_type}" for {os.path.basename(image)}. '
162
+ f"Face indexing accepts {', '.join(SUPPORTED_FACE_IMAGE_TYPES)} - "
163
+ "install Pillow to convert other formats automatically."
164
+ )
165
+
166
+ converted = f"{os.path.splitext(image)[0]}.authenta.jpg"
167
+ with Image.open(image) as handle:
168
+ # Convert to RGB and save: this also bakes any EXIF rotation into
169
+ # the pixels, which is what face detection relies on.
170
+ handle.convert("RGB").save(converted, "JPEG", quality=95)
171
+ return converted, "image/jpeg"
172
+
173
+ def search_files(self, image: str) -> Dict[str, Any]:
174
+ """Build the ``files=`` mapping for a multipart search request."""
175
+ path, content_type = self.prepare_search_image(image)
176
+ return {"path": path, "content_type": content_type}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authentasdk
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Python SDK for the Authenta API to detect deepfakes and manipulated media
5
5
  Home-page: https://github.com/phospheneai/authenta-python-sdk
6
6
  Author: Arjun Adhithya
@@ -32,7 +32,8 @@ Welcome to the official documentation for the **Authenta Python SDK** — your g
32
32
  - [4.3 FI-1 — Face Intelligence](#43-fi-1--face-intelligence)
33
33
  - [4.4 FE-1 — Face Embedding](#44-fe-1--face-embedding)
34
34
  - [4.5 DI-1 — Document Forgery Detection](#45-di-1--document-forgery-detection)
35
- - [4.6 Media Management](#46-media-management)
35
+ - [4.6 Face Indexing](#46-face-indexing)
36
+ - [4.7 Media Management](#47-media-management)
36
37
  5. [Visualization](#5-visualization)
37
38
  6. [Error Handling](#6-error-handling)
38
39
  7. [API Reference](#7-api-reference)
@@ -650,7 +651,89 @@ asyncio.run(detect_document())
650
651
 
651
652
  ---
652
653
 
653
- ### 4.6 Media Management
654
+ ### 4.6 Face Indexing
655
+
656
+ Index a person's photos, then find that face again later. This is separate from
657
+ the detection models — it runs no FI-1/DF-1/AC-1 job, and uses three endpoints
658
+ on the same host and API key:
659
+
660
+ | Method | Endpoint |
661
+ |---|---|
662
+ | `faceEnroll(images)` | `POST /api/v1/facesim/v1/enroll` |
663
+ | `tenants()` | `GET /api/v1/facesim/v1/subjects` |
664
+ | `faceSearch(image, limit)` | `POST /api/v1/facesim/v1/search` |
665
+
666
+ #### Enrol a face
667
+
668
+ ```python
669
+ enrolled = client.faceEnroll([
670
+ "data_samples/face_similiar/person_1/A.jpeg",
671
+ "data_samples/face_similiar/person_1/B.jpeg",
672
+ ])
673
+
674
+ print(enrolled["subject_id"])
675
+ for face in enrolled["faces"]:
676
+ print(face["face_id"], face["status"]) # "uploaded" or "failed"
677
+ ```
678
+
679
+ Accepts **1–10 images** of the same person, and only **JPEG, PNG, or WebP**.
680
+ Every file is validated before anything is sent, so a bad path never leaves a
681
+ half-built subject on the server.
682
+
683
+ `faceEnroll` returns as soon as the photos reach S3. The embeddings are
684
+ generated in the background, so each face comes back as `uploaded`, not
685
+ `processed` — poll `tenants()` to see them settle. One failed upload marks only
686
+ its own face rather than sinking the batch.
687
+
688
+ #### Check indexing progress
689
+
690
+ ```python
691
+ for subject in client.tenants()["subjects"]:
692
+ for face in subject["faces"]:
693
+ print(subject["subject_id"], face["name"], face["status"], face["error"])
694
+ ```
695
+
696
+ | Status | Meaning |
697
+ |---|---|
698
+ | `pending` | Upload URL created; the object has not been acknowledged yet |
699
+ | `uploaded` | Stored; waiting for a worker |
700
+ | `processing` | A worker is generating the embedding |
701
+ | `processed` | Embedding stored — the face is searchable |
702
+ | `failed` | Upload validation or embedding failed — see `error` |
703
+
704
+ Only `processed` faces are searched, so searching too early returns `count: 0`.
705
+
706
+ #### Search a face
707
+
708
+ ```python
709
+ matches = client.faceSearch("data_samples/face_similiar/person_1/A.jpeg", limit=10)
710
+
711
+ print(matches["count"])
712
+ for match in matches["results"]:
713
+ print(match["rank"], match["subject_id"], match["similarity_score"])
714
+ ```
715
+
716
+ The photo is posted as `multipart/form-data` rather than Base64 in a JSON body,
717
+ which keeps it clear of the server's 100 KiB JSON limit. `limit` is clamped to
718
+ 1–50 and defaults to 50.
719
+
720
+ Search is independent of enrolment: it matches everything already indexed on the
721
+ account, including from earlier sessions. The same subject can appear more than
722
+ once because every enrolled face has its own embedding, and results run from
723
+ highest similarity down.
724
+
725
+ Unsupported formats are converted to JPEG when **Pillow** is installed
726
+ (`pip install Pillow`); without it you get a clear `ValidationError` instead of
727
+ a server-side `invalid_image`. The conversion also bakes any EXIF rotation into
728
+ the pixels — an image left with a stale orientation tag is read sideways by face
729
+ detection and comes back as `no_face_detected`.
730
+
731
+ A full runnable script lives in
732
+ [`examples/test_face_indexing.py`](examples/test_face_indexing.py).
733
+
734
+ ---
735
+
736
+ ### 4.7 Media Management
654
737
 
655
738
  #### Get Media
656
739
 
@@ -960,6 +1043,40 @@ Raises `ValueError` if no result artifact is found.
960
1043
 
961
1044
  ---
962
1045
 
1046
+ #### `faceEnroll(images) -> Dict`
1047
+
1048
+ Enrol 1–10 photos of one person: creates the subject, then uploads each image
1049
+ to its presigned S3 URL.
1050
+
1051
+ | Parameter | Type | Description |
1052
+ |---|---|---|
1053
+ | `images` | `list[str]` | Local paths. JPEG, PNG, or WebP only |
1054
+
1055
+ Returns the enroll response — `subject_id`, `status`, and a `faces` array whose
1056
+ `status` is set to `uploaded` or `failed` per image. Embeddings follow out of
1057
+ band; poll `tenants()` for `processed`.
1058
+
1059
+ Raises `ValidationError` for an empty list, more than 10 images, a missing file,
1060
+ or an unsupported image type — all checked before any request is sent.
1061
+
1062
+ #### `tenants() -> Dict`
1063
+
1064
+ Every subject and face on the account: `{"tenant_id", "subjects"}`, where each
1065
+ subject holds `face_id`, `name`, `status`, `embedding`, `image_url`, and `error`.
1066
+
1067
+ #### `faceSearch(image, limit=50, timeout=120.0) -> Dict`
1068
+
1069
+ Rank enrolled faces against a query photo, posted as `multipart/form-data`.
1070
+
1071
+ | Parameter | Type | Default | Description |
1072
+ |---|---|---|---|
1073
+ | `image` | `str` | — | Local path to the query photo |
1074
+ | `limit` | `int` | `50` | Matches to return, clamped to 1–50 |
1075
+ | `timeout` | `float` | `120.0` | Seconds — embedding takes longer than a plain request |
1076
+
1077
+ Returns `{"tenant_id", "count", "results"}` with results ordered from highest
1078
+ similarity down. Only faces with `status: "processed"` are searched.
1079
+
963
1080
  #### `get_media(jobid) -> Dict`
964
1081
 
965
1082
  `GET /api/v1/jobs/{jobid}` — fetch the current state of a media record.
@@ -5,6 +5,7 @@ src/authenta/__init__.py
5
5
  src/authenta/async_authenta_client.py
6
6
  src/authenta/authenta_client.py
7
7
  src/authenta/authenta_exceptions.py
8
+ src/authenta/face_auth.py
8
9
  src/authenta/visualization.py
9
10
  src/authentasdk.egg-info/PKG-INFO
10
11
  src/authentasdk.egg-info/SOURCES.txt
File without changes