camera-ui-ml 1.2.21__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.
- camera_ui_ml/__init__.py +131 -0
- camera_ui_ml/align.py +58 -0
- camera_ui_ml/backend.py +65 -0
- camera_ui_ml/detectors/__init__.py +23 -0
- camera_ui_ml/detectors/base.py +82 -0
- camera_ui_ml/detectors/box.py +118 -0
- camera_ui_ml/detectors/clip.py +153 -0
- camera_ui_ml/detectors/embedder.py +66 -0
- camera_ui_ml/detectors/landmarks.py +63 -0
- camera_ui_ml/detectors/ocr.py +89 -0
- camera_ui_ml/detectors/person.py +44 -0
- camera_ui_ml/detectors/segmenter.py +120 -0
- camera_ui_ml/geometry.py +63 -0
- camera_ui_ml/model_manager.py +120 -0
- camera_ui_ml/parsing.py +151 -0
- camera_ui_ml/pipeline.py +20 -0
- camera_ui_ml/pipelines.py +343 -0
- camera_ui_ml/preprocess.py +70 -0
- camera_ui_ml/py.typed +0 -0
- camera_ui_ml/runtime.py +35 -0
- camera_ui_ml/settings.py +12 -0
- camera_ui_ml-1.2.21.dist-info/METADATA +42 -0
- camera_ui_ml-1.2.21.dist-info/RECORD +26 -0
- camera_ui_ml-1.2.21.dist-info/WHEEL +5 -0
- camera_ui_ml-1.2.21.dist-info/licenses/LICENSE.md +22 -0
- camera_ui_ml-1.2.21.dist-info/top_level.txt +1 -0
camera_ui_ml/__init__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .backend import (
|
|
4
|
+
DType,
|
|
5
|
+
InferenceBackend,
|
|
6
|
+
InputSpec,
|
|
7
|
+
Layout,
|
|
8
|
+
NDArray,
|
|
9
|
+
Normalize,
|
|
10
|
+
Outputs,
|
|
11
|
+
)
|
|
12
|
+
from .detectors import (
|
|
13
|
+
BaseDetector,
|
|
14
|
+
BoxDetector,
|
|
15
|
+
Embedder,
|
|
16
|
+
LandmarkDetector,
|
|
17
|
+
NormalizedDetection,
|
|
18
|
+
OcrResult,
|
|
19
|
+
ParseKind,
|
|
20
|
+
PersonEmbedder,
|
|
21
|
+
PlateOcr,
|
|
22
|
+
Segmenter,
|
|
23
|
+
)
|
|
24
|
+
from .geometry import (
|
|
25
|
+
Box,
|
|
26
|
+
NormalizedBox,
|
|
27
|
+
clamp_box,
|
|
28
|
+
cxcywh_to_xyxy,
|
|
29
|
+
normalize_box,
|
|
30
|
+
pad_box,
|
|
31
|
+
scale_box,
|
|
32
|
+
)
|
|
33
|
+
from .model_manager import BaseModelManager
|
|
34
|
+
from .parsing import (
|
|
35
|
+
RawDetection,
|
|
36
|
+
channels_first,
|
|
37
|
+
decode_ocr,
|
|
38
|
+
l2_normalize,
|
|
39
|
+
nms,
|
|
40
|
+
parse_end2end,
|
|
41
|
+
parse_yolov9,
|
|
42
|
+
)
|
|
43
|
+
from .pipeline import prepare_executor, run_prepare
|
|
44
|
+
from .pipelines import (
|
|
45
|
+
DETECTION_FLOOR,
|
|
46
|
+
detect_clip,
|
|
47
|
+
detect_faces,
|
|
48
|
+
detect_faces_in_image,
|
|
49
|
+
detect_objects,
|
|
50
|
+
detect_objects_in_image,
|
|
51
|
+
detect_plates,
|
|
52
|
+
detect_plates_in_image,
|
|
53
|
+
embed_face_images,
|
|
54
|
+
embed_faces,
|
|
55
|
+
embed_person_images,
|
|
56
|
+
embed_persons,
|
|
57
|
+
requested_threshold,
|
|
58
|
+
segment_images,
|
|
59
|
+
segment_objects,
|
|
60
|
+
)
|
|
61
|
+
from .preprocess import (
|
|
62
|
+
crop_rgb,
|
|
63
|
+
decode_image,
|
|
64
|
+
frame_to_rgb,
|
|
65
|
+
resize_rgb,
|
|
66
|
+
to_pil,
|
|
67
|
+
to_tensor,
|
|
68
|
+
)
|
|
69
|
+
from .runtime import model_runtime
|
|
70
|
+
from .settings import reset_stored_settings
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"model_runtime",
|
|
74
|
+
"InferenceBackend",
|
|
75
|
+
"InputSpec",
|
|
76
|
+
"Outputs",
|
|
77
|
+
"NDArray",
|
|
78
|
+
"Layout",
|
|
79
|
+
"Normalize",
|
|
80
|
+
"DType",
|
|
81
|
+
"BaseModelManager",
|
|
82
|
+
# ClipEncoder is opt-in via camera_ui_ml.detectors.clip
|
|
83
|
+
"BaseDetector",
|
|
84
|
+
"BoxDetector",
|
|
85
|
+
"NormalizedDetection",
|
|
86
|
+
"ParseKind",
|
|
87
|
+
"Embedder",
|
|
88
|
+
"LandmarkDetector",
|
|
89
|
+
"PersonEmbedder",
|
|
90
|
+
"Segmenter",
|
|
91
|
+
"PlateOcr",
|
|
92
|
+
"OcrResult",
|
|
93
|
+
"Box",
|
|
94
|
+
"NormalizedBox",
|
|
95
|
+
"cxcywh_to_xyxy",
|
|
96
|
+
"scale_box",
|
|
97
|
+
"clamp_box",
|
|
98
|
+
"normalize_box",
|
|
99
|
+
"pad_box",
|
|
100
|
+
"RawDetection",
|
|
101
|
+
"channels_first",
|
|
102
|
+
"parse_yolov9",
|
|
103
|
+
"parse_end2end",
|
|
104
|
+
"decode_ocr",
|
|
105
|
+
"l2_normalize",
|
|
106
|
+
"nms",
|
|
107
|
+
"frame_to_rgb",
|
|
108
|
+
"resize_rgb",
|
|
109
|
+
"to_pil",
|
|
110
|
+
"to_tensor",
|
|
111
|
+
"crop_rgb",
|
|
112
|
+
"decode_image",
|
|
113
|
+
"run_prepare",
|
|
114
|
+
"prepare_executor",
|
|
115
|
+
"DETECTION_FLOOR",
|
|
116
|
+
"requested_threshold",
|
|
117
|
+
"detect_objects",
|
|
118
|
+
"detect_faces",
|
|
119
|
+
"detect_plates",
|
|
120
|
+
"detect_objects_in_image",
|
|
121
|
+
"detect_faces_in_image",
|
|
122
|
+
"detect_plates_in_image",
|
|
123
|
+
"reset_stored_settings",
|
|
124
|
+
"detect_clip",
|
|
125
|
+
"embed_faces",
|
|
126
|
+
"embed_face_images",
|
|
127
|
+
"embed_persons",
|
|
128
|
+
"embed_person_images",
|
|
129
|
+
"segment_objects",
|
|
130
|
+
"segment_images",
|
|
131
|
+
]
|
camera_ui_ml/align.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
NDArray = np.ndarray[Any, Any]
|
|
9
|
+
|
|
10
|
+
ARCFACE_DST_112 = np.array(
|
|
11
|
+
[
|
|
12
|
+
[38.2946, 51.6963],
|
|
13
|
+
[73.5318, 51.5014],
|
|
14
|
+
[56.0252, 71.7366],
|
|
15
|
+
[41.5493, 92.3655],
|
|
16
|
+
[70.7299, 92.2041],
|
|
17
|
+
],
|
|
18
|
+
dtype=np.float64,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def umeyama_similarity(src: NDArray, dst: NDArray) -> NDArray:
|
|
23
|
+
src = np.asarray(src, dtype=np.float64)
|
|
24
|
+
dst = np.asarray(dst, dtype=np.float64)
|
|
25
|
+
n, dim = src.shape
|
|
26
|
+
|
|
27
|
+
src_mean, dst_mean = src.mean(axis=0), dst.mean(axis=0)
|
|
28
|
+
src_demean, dst_demean = src - src_mean, dst - dst_mean
|
|
29
|
+
|
|
30
|
+
cov = dst_demean.T @ src_demean / n
|
|
31
|
+
u, s, vt = np.linalg.svd(cov)
|
|
32
|
+
|
|
33
|
+
d = np.ones(dim)
|
|
34
|
+
if np.linalg.det(u) * np.linalg.det(vt) < 0:
|
|
35
|
+
d[-1] = -1.0
|
|
36
|
+
rotation = u @ np.diag(d) @ vt
|
|
37
|
+
|
|
38
|
+
variance = (src_demean**2).sum() / n
|
|
39
|
+
scale = 1.0 if variance == 0 else float((s * d).sum() / variance)
|
|
40
|
+
|
|
41
|
+
matrix = np.zeros((2, 3), dtype=np.float64)
|
|
42
|
+
matrix[:, :2] = scale * rotation
|
|
43
|
+
matrix[:, 2] = dst_mean - scale * rotation @ src_mean
|
|
44
|
+
return matrix
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def warp_face(rgb: NDArray, points: NDArray, size: int = 112) -> NDArray:
|
|
48
|
+
scale = size / 112.0
|
|
49
|
+
matrix = umeyama_similarity(np.asarray(points, dtype=np.float64), ARCFACE_DST_112 * scale)
|
|
50
|
+
|
|
51
|
+
# PIL maps destination pixels back to the source, so it wants the inverse
|
|
52
|
+
full = np.vstack([matrix, [0.0, 0.0, 1.0]])
|
|
53
|
+
inverse = np.linalg.inv(full)[:2]
|
|
54
|
+
|
|
55
|
+
warped = Image.fromarray(rgb).transform(
|
|
56
|
+
(size, size), Image.Transform.AFFINE, tuple(inverse.flatten()), resample=Image.Resampling.BILINEAR
|
|
57
|
+
)
|
|
58
|
+
return np.asarray(warped)
|
camera_ui_ml/backend.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .pipeline import run_prepare
|
|
11
|
+
|
|
12
|
+
NDArray = np.ndarray[Any, Any]
|
|
13
|
+
|
|
14
|
+
Layout = Literal["nchw", "nhwc"]
|
|
15
|
+
Normalize = Literal["unit", "facenet", "arcface", "none"]
|
|
16
|
+
Channels = Literal["rgb", "bgr"]
|
|
17
|
+
DType = Literal["float32", "uint8"]
|
|
18
|
+
|
|
19
|
+
Outputs = Sequence[NDArray]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class InputSpec:
|
|
24
|
+
width: int
|
|
25
|
+
height: int
|
|
26
|
+
layout: Layout = "nchw"
|
|
27
|
+
normalize: Normalize = "unit"
|
|
28
|
+
channels: Channels = "rgb"
|
|
29
|
+
dtype: DType = "float32"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InferenceBackend(ABC):
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def input_size(self) -> tuple[int, int]: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def metadata(self) -> Mapping[str, str]: ...
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def device(self) -> str:
|
|
42
|
+
return "unknown"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def runtime(self) -> str | None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def precision(self) -> str | None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def infer(self, inputs: Sequence[Any]) -> Outputs: ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def close(self) -> None: ...
|
|
57
|
+
|
|
58
|
+
def adapt(self, image: NDArray, spec: InputSpec) -> Sequence[Any]:
|
|
59
|
+
from .preprocess import to_tensor
|
|
60
|
+
|
|
61
|
+
return [to_tensor(image, spec)]
|
|
62
|
+
|
|
63
|
+
async def run(self, image: NDArray, spec: InputSpec) -> Outputs:
|
|
64
|
+
prepared = await run_prepare(lambda: list(self.adapt(image, spec)))
|
|
65
|
+
return await self.infer(prepared)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import BaseDetector
|
|
4
|
+
from .box import BoxDetector, NormalizedDetection, ParseKind
|
|
5
|
+
from .embedder import Embedder
|
|
6
|
+
from .landmarks import LandmarkDetector
|
|
7
|
+
from .ocr import DEFAULT_ALPHABET, OcrResult, PlateOcr
|
|
8
|
+
from .person import PersonEmbedder
|
|
9
|
+
from .segmenter import Segmenter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BaseDetector",
|
|
13
|
+
"BoxDetector",
|
|
14
|
+
"NormalizedDetection",
|
|
15
|
+
"ParseKind",
|
|
16
|
+
"Embedder",
|
|
17
|
+
"LandmarkDetector",
|
|
18
|
+
"PersonEmbedder",
|
|
19
|
+
"Segmenter",
|
|
20
|
+
"PlateOcr",
|
|
21
|
+
"OcrResult",
|
|
22
|
+
"DEFAULT_ALPHABET",
|
|
23
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from camera_ui_sdk import LoggerService
|
|
8
|
+
|
|
9
|
+
from ..backend import InferenceBackend
|
|
10
|
+
from ..model_manager import BaseModelManager
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from camera_ui_sdk import LoadedModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseDetector:
|
|
17
|
+
name: str = "detector"
|
|
18
|
+
|
|
19
|
+
def __init__(self, manager: BaseModelManager, logger: LoggerService) -> None:
|
|
20
|
+
self.manager = manager
|
|
21
|
+
self.logger = logger
|
|
22
|
+
self.backend: InferenceBackend | None = None
|
|
23
|
+
self.initialized = False
|
|
24
|
+
self.closed = False
|
|
25
|
+
self.model_name: str | None = None
|
|
26
|
+
self.load_ms = 0
|
|
27
|
+
self._init_task: asyncio.Task[None] | None = None
|
|
28
|
+
|
|
29
|
+
async def initialize(self, model_name: str) -> None:
|
|
30
|
+
if self.initialized:
|
|
31
|
+
return
|
|
32
|
+
if self._init_task is None:
|
|
33
|
+
self._init_task = asyncio.create_task(self._do_initialize(model_name))
|
|
34
|
+
await self._init_task
|
|
35
|
+
|
|
36
|
+
async def close(self) -> None:
|
|
37
|
+
self.closed = True
|
|
38
|
+
if self._init_task is not None:
|
|
39
|
+
self._init_task.cancel()
|
|
40
|
+
self._init_task = None
|
|
41
|
+
self.initialized = False
|
|
42
|
+
if self.backend is not None:
|
|
43
|
+
self.backend.close()
|
|
44
|
+
self.backend = None
|
|
45
|
+
|
|
46
|
+
async def _do_initialize(self, model_name: str) -> None:
|
|
47
|
+
try:
|
|
48
|
+
self.logger.log(f"Loading {self.name}: {model_name}...")
|
|
49
|
+
started = time.monotonic()
|
|
50
|
+
self.backend = await self.manager.ensure_backend(model_name)
|
|
51
|
+
if self.closed:
|
|
52
|
+
return
|
|
53
|
+
await self._configure(model_name)
|
|
54
|
+
self.load_ms = round((time.monotonic() - started) * 1000)
|
|
55
|
+
self.model_name = model_name
|
|
56
|
+
self.initialized = True
|
|
57
|
+
self.logger.success(f"Loaded {self.name}: {model_name}")
|
|
58
|
+
except Exception as error:
|
|
59
|
+
self.logger.error(f"Failed to initialize {self.name}: {error}")
|
|
60
|
+
raise
|
|
61
|
+
finally:
|
|
62
|
+
self._init_task = None
|
|
63
|
+
|
|
64
|
+
async def _configure(self, model_name: str) -> None: ...
|
|
65
|
+
|
|
66
|
+
def loaded_model(self, role: str | None = None) -> LoadedModel | None:
|
|
67
|
+
if not self._ready() or not self.model_name or self.backend is None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
entry: LoadedModel = {"name": self.model_name}
|
|
71
|
+
if role:
|
|
72
|
+
entry["role"] = role
|
|
73
|
+
if self.backend.device and self.backend.device != "unknown":
|
|
74
|
+
entry["device"] = self.backend.device
|
|
75
|
+
if self.backend.precision:
|
|
76
|
+
entry["precision"] = self.backend.precision
|
|
77
|
+
if self.load_ms:
|
|
78
|
+
entry["loadMs"] = self.load_ms
|
|
79
|
+
return entry
|
|
80
|
+
|
|
81
|
+
def _ready(self) -> bool:
|
|
82
|
+
return self.initialized and self.backend is not None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from camera_ui_sdk import ImageMetadata, LoggerService, VideoFrameData
|
|
10
|
+
|
|
11
|
+
from ..backend import InputSpec, NDArray, Normalize
|
|
12
|
+
from ..geometry import NormalizedBox, normalize_box, scale_box
|
|
13
|
+
from ..model_manager import BaseModelManager
|
|
14
|
+
from ..parsing import RawDetection, channels_first, nms, parse_end2end, parse_yolov9
|
|
15
|
+
from ..preprocess import decode_image, frame_to_rgb
|
|
16
|
+
from .base import BaseDetector
|
|
17
|
+
|
|
18
|
+
ParseKind = Literal["yolov9", "end2end"]
|
|
19
|
+
NormalizedDetection = tuple[int, float, NormalizedBox]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BoxDetector(BaseDetector):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
manager: BaseModelManager,
|
|
26
|
+
logger: LoggerService,
|
|
27
|
+
*,
|
|
28
|
+
name: str = "detector",
|
|
29
|
+
parse: ParseKind = "yolov9",
|
|
30
|
+
size: tuple[int, int] = (320, 320),
|
|
31
|
+
normalize: Normalize = "unit",
|
|
32
|
+
multiclass: bool = False,
|
|
33
|
+
apply_nms: bool = True,
|
|
34
|
+
threshold: float = 0.5,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(manager, logger)
|
|
37
|
+
self.name = name
|
|
38
|
+
self.threshold = threshold
|
|
39
|
+
self.input_size = size
|
|
40
|
+
self.labels: dict[int, str] = {}
|
|
41
|
+
|
|
42
|
+
self._parse = parse
|
|
43
|
+
self._normalize = normalize
|
|
44
|
+
self._multiclass = multiclass
|
|
45
|
+
self._apply_nms = apply_nms
|
|
46
|
+
|
|
47
|
+
async def _configure(self, model_name: str) -> None:
|
|
48
|
+
assert self.backend is not None
|
|
49
|
+
self.input_size = self.backend.input_size
|
|
50
|
+
if self._multiclass:
|
|
51
|
+
self.labels = _parse_labels(self.backend.metadata())
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def _spec(self) -> InputSpec:
|
|
55
|
+
return InputSpec(
|
|
56
|
+
self.input_size[0],
|
|
57
|
+
self.input_size[1],
|
|
58
|
+
layout="nchw",
|
|
59
|
+
normalize=self._normalize,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def detect(self, image: NDArray, threshold: float | None = None) -> list[RawDetection]:
|
|
63
|
+
if not self._ready():
|
|
64
|
+
return []
|
|
65
|
+
assert self.backend is not None
|
|
66
|
+
|
|
67
|
+
outputs = await self.backend.run(image, self._spec)
|
|
68
|
+
primary = np.asarray(outputs[0])
|
|
69
|
+
limit = self.threshold if threshold is None else threshold
|
|
70
|
+
|
|
71
|
+
if self._parse == "yolov9":
|
|
72
|
+
detections = parse_yolov9(channels_first(primary), limit)
|
|
73
|
+
else:
|
|
74
|
+
detections = parse_end2end(np.squeeze(primary), limit)
|
|
75
|
+
|
|
76
|
+
return nms(detections) if self._apply_nms else detections
|
|
77
|
+
|
|
78
|
+
async def detect_frame(self, frame: VideoFrameData, threshold: float | None = None) -> list[RawDetection]:
|
|
79
|
+
if not self._ready():
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
rgb = frame_to_rgb(frame["data"], frame["width"], frame["height"], frame["format"])
|
|
83
|
+
raw = await self.detect(rgb, threshold)
|
|
84
|
+
|
|
85
|
+
scale_x = frame["width"] / self.input_size[0]
|
|
86
|
+
scale_y = frame["height"] / self.input_size[1]
|
|
87
|
+
return [(cid, conf, scale_box(box, scale_x, scale_y)) for cid, conf, box in raw]
|
|
88
|
+
|
|
89
|
+
async def detect_single(
|
|
90
|
+
self, image_data: bytes, metadata: ImageMetadata, threshold: float | None = None
|
|
91
|
+
) -> list[NormalizedDetection]:
|
|
92
|
+
if not self._ready():
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
raw = await self.detect(decode_image(image_data), threshold)
|
|
96
|
+
return [
|
|
97
|
+
(cid, conf, normalize_box(box, self.input_size[0], self.input_size[1])) for cid, conf, box in raw
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_labels(metadata: Mapping[str, str]) -> dict[int, str]:
|
|
102
|
+
names = metadata.get("names") or metadata.get("yolo.names")
|
|
103
|
+
if names:
|
|
104
|
+
parsed: dict[Any, Any] = ast.literal_eval(names)
|
|
105
|
+
return {int(key): str(value) for key, value in parsed.items()}
|
|
106
|
+
|
|
107
|
+
classes = metadata.get("classes")
|
|
108
|
+
if not classes:
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
labels: dict[int, str] = {}
|
|
112
|
+
for row, line in enumerate(classes.split(",")):
|
|
113
|
+
pair = re.split(r"[:\s]+", line.strip(), maxsplit=1)
|
|
114
|
+
if len(pair) == 2 and pair[0].strip().isdigit():
|
|
115
|
+
labels[int(pair[0])] = pair[1].strip()
|
|
116
|
+
else:
|
|
117
|
+
labels[row] = line.strip()
|
|
118
|
+
return labels
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from camera_ui_sdk import LoggerService, VideoFrameData
|
|
9
|
+
from transformers import CLIPProcessor
|
|
10
|
+
|
|
11
|
+
from ..backend import InferenceBackend, NDArray
|
|
12
|
+
from ..model_manager import BaseModelManager
|
|
13
|
+
from ..parsing import l2_normalize
|
|
14
|
+
from ..pipeline import run_prepare
|
|
15
|
+
from ..preprocess import frame_to_rgb, to_pil
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from camera_ui_sdk import LoadedModel
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClipEncoder:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
manager: BaseModelManager,
|
|
25
|
+
logger: LoggerService,
|
|
26
|
+
*,
|
|
27
|
+
embedding_model: str = "clip-vit-base-patch32",
|
|
28
|
+
) -> None:
|
|
29
|
+
self.manager = manager
|
|
30
|
+
self.logger = logger
|
|
31
|
+
self.embedding_model = embedding_model
|
|
32
|
+
|
|
33
|
+
self.vision: InferenceBackend | None = None
|
|
34
|
+
self.text: InferenceBackend | None = None
|
|
35
|
+
self.processor: Any = None
|
|
36
|
+
|
|
37
|
+
self.initialized = False
|
|
38
|
+
self.closed = False
|
|
39
|
+
self.vision_model: str | None = None
|
|
40
|
+
self.text_model: str | None = None
|
|
41
|
+
self.load_ms = 0
|
|
42
|
+
self._init_task: asyncio.Task[None] | None = None
|
|
43
|
+
|
|
44
|
+
async def initialize(self, vision_model: str, text_model: str) -> None:
|
|
45
|
+
if self.initialized:
|
|
46
|
+
return
|
|
47
|
+
if self._init_task is None:
|
|
48
|
+
self._init_task = asyncio.create_task(self._do_initialize(vision_model, text_model))
|
|
49
|
+
await self._init_task
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None:
|
|
52
|
+
self.closed = True
|
|
53
|
+
if self._init_task is not None:
|
|
54
|
+
self._init_task.cancel()
|
|
55
|
+
self._init_task = None
|
|
56
|
+
self.initialized = False
|
|
57
|
+
if self.vision is not None:
|
|
58
|
+
self.vision.close()
|
|
59
|
+
self.vision = None
|
|
60
|
+
if self.text is not None:
|
|
61
|
+
self.text.close()
|
|
62
|
+
self.text = None
|
|
63
|
+
self.processor = None
|
|
64
|
+
|
|
65
|
+
async def embed_image(self, image: NDArray) -> list[float]:
|
|
66
|
+
if not self._ready():
|
|
67
|
+
return []
|
|
68
|
+
assert self.vision is not None
|
|
69
|
+
|
|
70
|
+
pil = to_pil(image)
|
|
71
|
+
pixel_values = await run_prepare(lambda: self._vision_input(pil))
|
|
72
|
+
outputs = await self.vision.infer([pixel_values])
|
|
73
|
+
return [float(value) for value in l2_normalize(outputs[0])]
|
|
74
|
+
|
|
75
|
+
async def embed_text(self, text: str) -> list[float]:
|
|
76
|
+
if not self._ready():
|
|
77
|
+
return []
|
|
78
|
+
assert self.text is not None
|
|
79
|
+
|
|
80
|
+
input_ids, attention_mask = await run_prepare(lambda: self._text_input(text))
|
|
81
|
+
outputs = await self.text.infer([input_ids, attention_mask])
|
|
82
|
+
return [float(value) for value in l2_normalize(outputs[0])]
|
|
83
|
+
|
|
84
|
+
async def embed_frame(self, width: int, height: int, data: bytes) -> list[float]:
|
|
85
|
+
return await self.embed_image(frame_to_rgb(data, width, height))
|
|
86
|
+
|
|
87
|
+
async def embed_frames(self, frames: list[VideoFrameData]) -> list[list[float]]:
|
|
88
|
+
if not self._ready():
|
|
89
|
+
return [[] for _ in frames]
|
|
90
|
+
return [await self.embed_frame(frame["width"], frame["height"], frame["data"]) for frame in frames]
|
|
91
|
+
|
|
92
|
+
def loaded_models(self, _role: str | None = None) -> list[LoadedModel]:
|
|
93
|
+
if not self._ready():
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
entries: list[LoadedModel] = []
|
|
97
|
+
for backend, name, role in (
|
|
98
|
+
(self.vision, self.vision_model, "vision"),
|
|
99
|
+
(self.text, self.text_model, "text"),
|
|
100
|
+
):
|
|
101
|
+
if backend is None or not name:
|
|
102
|
+
continue
|
|
103
|
+
entry: LoadedModel = {"name": name, "role": role}
|
|
104
|
+
if backend.device and backend.device != "unknown":
|
|
105
|
+
entry["device"] = backend.device
|
|
106
|
+
if backend.precision:
|
|
107
|
+
entry["precision"] = backend.precision
|
|
108
|
+
if self.load_ms:
|
|
109
|
+
entry["loadMs"] = self.load_ms
|
|
110
|
+
entries.append(entry)
|
|
111
|
+
return entries
|
|
112
|
+
|
|
113
|
+
def _ready(self) -> bool:
|
|
114
|
+
return (
|
|
115
|
+
self.initialized
|
|
116
|
+
and self.vision is not None
|
|
117
|
+
and self.text is not None
|
|
118
|
+
and self.processor is not None
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def _do_initialize(self, vision_model: str, text_model: str) -> None:
|
|
122
|
+
try:
|
|
123
|
+
self.logger.log(f"Loading CLIP: {vision_model} + {text_model}...")
|
|
124
|
+
started = time.monotonic()
|
|
125
|
+
self.vision = await self.manager.ensure_backend(vision_model)
|
|
126
|
+
self.text = await self.manager.ensure_backend(text_model)
|
|
127
|
+
processor_dir = await self.manager.ensure_clip_processor()
|
|
128
|
+
if processor_dir is None:
|
|
129
|
+
raise RuntimeError("CLIP processor files are not bundled (clip_processor_files is empty)")
|
|
130
|
+
self.processor = await asyncio.to_thread(CLIPProcessor.from_pretrained, processor_dir)
|
|
131
|
+
if self.closed:
|
|
132
|
+
return
|
|
133
|
+
self.load_ms = round((time.monotonic() - started) * 1000)
|
|
134
|
+
self.vision_model = vision_model
|
|
135
|
+
self.text_model = text_model
|
|
136
|
+
self.initialized = True
|
|
137
|
+
self.logger.success(f"Loaded CLIP: {vision_model} + {text_model}")
|
|
138
|
+
except Exception as error:
|
|
139
|
+
self.logger.error(f"Failed to initialize CLIP encoder: {error}")
|
|
140
|
+
raise
|
|
141
|
+
finally:
|
|
142
|
+
self._init_task = None
|
|
143
|
+
|
|
144
|
+
def _vision_input(self, pil: Any) -> NDArray:
|
|
145
|
+
inputs = self.processor(images=pil, return_tensors="np", padding="max_length", truncation=True)
|
|
146
|
+
return np.asarray(inputs["pixel_values"], dtype=np.float32)
|
|
147
|
+
|
|
148
|
+
def _text_input(self, text: str) -> tuple[NDArray, NDArray]:
|
|
149
|
+
inputs = self.processor(text=text, return_tensors="np", padding="max_length", truncation=True)
|
|
150
|
+
return (
|
|
151
|
+
np.asarray(inputs["input_ids"], dtype=np.int64),
|
|
152
|
+
np.asarray(inputs["attention_mask"], dtype=np.int64),
|
|
153
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from camera_ui_sdk import LoggerService
|
|
4
|
+
|
|
5
|
+
from ..align import warp_face
|
|
6
|
+
from ..backend import InputSpec, NDArray, Normalize
|
|
7
|
+
from ..geometry import Box, square_box
|
|
8
|
+
from ..model_manager import BaseModelManager
|
|
9
|
+
from ..parsing import FaceLandmarks, l2_normalize
|
|
10
|
+
from ..preprocess import crop_rgb, frame_to_rgb
|
|
11
|
+
from .base import BaseDetector
|
|
12
|
+
|
|
13
|
+
# whole-picture enrolment against a live crop: cosine 0.14, located and cut like this: 1.00
|
|
14
|
+
FACE_PADDING = 0.25
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Embedder(BaseDetector):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
manager: BaseModelManager,
|
|
21
|
+
logger: LoggerService,
|
|
22
|
+
*,
|
|
23
|
+
size: int = 160,
|
|
24
|
+
normalize: Normalize = "facenet",
|
|
25
|
+
aligned: bool = False,
|
|
26
|
+
name: str = "face embedder",
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(manager, logger)
|
|
29
|
+
self.name = name
|
|
30
|
+
self.input_size = (size, size)
|
|
31
|
+
self.normalize = normalize
|
|
32
|
+
# ArcFace-style heads need the 112x112 template, a plain box crop drops them from 51% to near zero
|
|
33
|
+
self.aligned = aligned
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def _spec(self) -> InputSpec:
|
|
37
|
+
return InputSpec(self.input_size[0], self.input_size[1], layout="nchw", normalize=self.normalize)
|
|
38
|
+
|
|
39
|
+
async def embed(self, image: NDArray) -> list[float]:
|
|
40
|
+
if not self._ready():
|
|
41
|
+
return []
|
|
42
|
+
assert self.backend is not None
|
|
43
|
+
outputs = await self.backend.run(image, self._spec)
|
|
44
|
+
return [float(value) for value in l2_normalize(outputs[0])]
|
|
45
|
+
|
|
46
|
+
async def embed_from_crop(self, frame_data: bytes, width: int, height: int, box: Box) -> list[float]:
|
|
47
|
+
if not self._ready():
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
x1 = max(0, int(box[0]))
|
|
51
|
+
y1 = max(0, int(box[1]))
|
|
52
|
+
x2 = min(width, int(box[2]))
|
|
53
|
+
y2 = min(height, int(box[3]))
|
|
54
|
+
if x2 <= x1 or y2 <= y1:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
rgb = frame_to_rgb(frame_data, width, height)
|
|
58
|
+
return await self.embed(rgb[y1:y2, x1:x2])
|
|
59
|
+
|
|
60
|
+
async def embed_face(self, crop: NDArray, landmarks: FaceLandmarks | None) -> list[float]:
|
|
61
|
+
if not self._ready() or crop.size == 0 or landmarks is None:
|
|
62
|
+
return []
|
|
63
|
+
if self.aligned:
|
|
64
|
+
return await self.embed(warp_face(crop, landmarks.points, self.input_size[0]))
|
|
65
|
+
box = square_box(landmarks.box, crop.shape[1], crop.shape[0], FACE_PADDING)
|
|
66
|
+
return await self.embed(crop_rgb(crop, box))
|