vws-python-mock 2026.8.4__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.
- mock_vws/__init__.py +9 -0
- mock_vws/_base64_decoding.py +35 -0
- mock_vws/_constants.py +84 -0
- mock_vws/_database_matchers.py +107 -0
- mock_vws/_flask_server/Dockerfile +32 -0
- mock_vws/_flask_server/__init__.py +1 -0
- mock_vws/_flask_server/healthcheck.py +31 -0
- mock_vws/_flask_server/target_manager.py +447 -0
- mock_vws/_flask_server/vwq.py +173 -0
- mock_vws/_flask_server/vws.py +954 -0
- mock_vws/_mock_common.py +75 -0
- mock_vws/_model_target_web_api.py +486 -0
- mock_vws/_query_tools.py +136 -0
- mock_vws/_query_validators/__init__.py +128 -0
- mock_vws/_query_validators/accept_header_validators.py +31 -0
- mock_vws/_query_validators/auth_validators.py +143 -0
- mock_vws/_query_validators/content_length_validators.py +90 -0
- mock_vws/_query_validators/content_type_validators.py +65 -0
- mock_vws/_query_validators/date_validators.py +110 -0
- mock_vws/_query_validators/exceptions.py +769 -0
- mock_vws/_query_validators/fields_validators.py +47 -0
- mock_vws/_query_validators/image_validators.py +197 -0
- mock_vws/_query_validators/include_target_data_validators.py +51 -0
- mock_vws/_query_validators/num_results_validators.py +64 -0
- mock_vws/_query_validators/project_state_validators.py +49 -0
- mock_vws/_requests_mock_server/__init__.py +1 -0
- mock_vws/_requests_mock_server/decorators.py +275 -0
- mock_vws/_requests_mock_server/mock_web_query_api.py +139 -0
- mock_vws/_requests_mock_server/mock_web_services_api.py +954 -0
- mock_vws/_respx_mock_server/__init__.py +1 -0
- mock_vws/_respx_mock_server/decorators.py +186 -0
- mock_vws/_services_validators/__init__.py +186 -0
- mock_vws/_services_validators/active_flag_validators.py +43 -0
- mock_vws/_services_validators/auth_validators.py +124 -0
- mock_vws/_services_validators/content_length_validators.py +102 -0
- mock_vws/_services_validators/content_type_validators.py +42 -0
- mock_vws/_services_validators/date_validators.py +78 -0
- mock_vws/_services_validators/exceptions.py +858 -0
- mock_vws/_services_validators/image_validators.py +220 -0
- mock_vws/_services_validators/json_validators.py +69 -0
- mock_vws/_services_validators/key_validators.py +171 -0
- mock_vws/_services_validators/metadata_validators.py +106 -0
- mock_vws/_services_validators/name_validators.py +235 -0
- mock_vws/_services_validators/project_state_validators.py +76 -0
- mock_vws/_services_validators/request_quota_validators.py +42 -0
- mock_vws/_services_validators/target_quota_validators.py +41 -0
- mock_vws/_services_validators/target_validators.py +69 -0
- mock_vws/_services_validators/width_validators.py +38 -0
- mock_vws/database.py +257 -0
- mock_vws/database_type.py +13 -0
- mock_vws/image_matchers.py +124 -0
- mock_vws/model_target.py +79 -0
- mock_vws/py.typed +0 -0
- mock_vws/states.py +20 -0
- mock_vws/target.py +285 -0
- mock_vws/target_manager.py +178 -0
- mock_vws/target_raters.py +109 -0
- vws_python_mock-2026.8.4.dist-info/METADATA +177 -0
- vws_python_mock-2026.8.4.dist-info/RECORD +62 -0
- vws_python_mock-2026.8.4.dist-info/WHEEL +5 -0
- vws_python_mock-2026.8.4.dist-info/licenses/LICENSE +21 -0
- vws_python_mock-2026.8.4.dist-info/top_level.txt +1 -0
mock_vws/target.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""A fake implementation of a target for the Vuforia Web Services API."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import datetime
|
|
5
|
+
import io
|
|
6
|
+
import statistics
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Self, TypedDict
|
|
10
|
+
from zoneinfo import ZoneInfo
|
|
11
|
+
|
|
12
|
+
from beartype import BeartypeConf, beartype
|
|
13
|
+
from PIL import Image, ImageStat
|
|
14
|
+
|
|
15
|
+
from mock_vws._constants import TargetStatuses
|
|
16
|
+
from mock_vws.target_raters import (
|
|
17
|
+
HardcodedTargetTrackingRater,
|
|
18
|
+
TargetTrackingRater,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VuMarkTargetDict(TypedDict):
|
|
23
|
+
"""A dictionary type which represents a VuMark target."""
|
|
24
|
+
|
|
25
|
+
target_id: str
|
|
26
|
+
name: str
|
|
27
|
+
processing_time_seconds: float
|
|
28
|
+
last_modified_date: str
|
|
29
|
+
upload_date: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ImageTargetDict(TypedDict):
|
|
33
|
+
"""A dictionary type which represents an image target."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
width: float
|
|
37
|
+
image_base64: str
|
|
38
|
+
active_flag: bool
|
|
39
|
+
processing_time_seconds: float
|
|
40
|
+
application_metadata: str | None
|
|
41
|
+
target_id: str
|
|
42
|
+
last_modified_date: str
|
|
43
|
+
delete_date_optional: str | None
|
|
44
|
+
upload_date: str
|
|
45
|
+
tracking_rating: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@beartype
|
|
49
|
+
def _random_hex() -> str:
|
|
50
|
+
"""Return a random hex value."""
|
|
51
|
+
return uuid.uuid4().hex
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@beartype
|
|
55
|
+
def _time_now() -> datetime.datetime:
|
|
56
|
+
"""Return the current time in the GMT time zone."""
|
|
57
|
+
gmt = ZoneInfo(key="GMT")
|
|
58
|
+
return datetime.datetime.now(tz=gmt)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
|
|
62
|
+
@dataclass(frozen=True, eq=True, kw_only=True)
|
|
63
|
+
class ImageTarget:
|
|
64
|
+
"""A Vuforia image target as managed in
|
|
65
|
+
https://developer.vuforia.com/target-manager.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
active_flag: bool
|
|
69
|
+
application_metadata: str | None
|
|
70
|
+
image_value: bytes
|
|
71
|
+
name: str
|
|
72
|
+
processing_time_seconds: float
|
|
73
|
+
width: float
|
|
74
|
+
target_tracking_rater: TargetTrackingRater = field(compare=False)
|
|
75
|
+
current_month_recos: int = 0
|
|
76
|
+
delete_date: datetime.datetime | None = None
|
|
77
|
+
last_modified_date: datetime.datetime = field(default_factory=_time_now)
|
|
78
|
+
previous_month_recos: int = 0
|
|
79
|
+
reco_rating: str = ""
|
|
80
|
+
target_id: str = field(default_factory=_random_hex)
|
|
81
|
+
total_recos: int = 0
|
|
82
|
+
upload_date: datetime.datetime = field(default_factory=_time_now)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def _post_processing_status(self) -> TargetStatuses:
|
|
86
|
+
"""Return the status of the target, or what it will be when
|
|
87
|
+
processing
|
|
88
|
+
is finished.
|
|
89
|
+
|
|
90
|
+
The status depends on the standard deviation of the color bands.
|
|
91
|
+
How VWS determines this is unknown, but it relates to how
|
|
92
|
+
suitable the target is for detection.
|
|
93
|
+
"""
|
|
94
|
+
image_file = io.BytesIO(initial_bytes=self.image_value)
|
|
95
|
+
with Image.open(fp=image_file) as image:
|
|
96
|
+
image_stat = ImageStat.Stat(image_or_list=image)
|
|
97
|
+
average_std_dev = statistics.mean(data=image_stat.stddev)
|
|
98
|
+
|
|
99
|
+
success_threshold = 5
|
|
100
|
+
|
|
101
|
+
if average_std_dev > success_threshold:
|
|
102
|
+
return TargetStatuses.SUCCESS
|
|
103
|
+
|
|
104
|
+
return TargetStatuses.FAILED
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def status(self) -> str:
|
|
108
|
+
"""Return the status of the target.
|
|
109
|
+
|
|
110
|
+
For now this waits half a second (arbitrary) before changing the
|
|
111
|
+
status from 'processing' to 'failed' or 'success'.
|
|
112
|
+
|
|
113
|
+
The status depends on the standard deviation of the color bands.
|
|
114
|
+
How VWS determines this is unknown, but it relates to how
|
|
115
|
+
suitable the target is for detection.
|
|
116
|
+
"""
|
|
117
|
+
processing_time = datetime.timedelta(
|
|
118
|
+
seconds=float(self.processing_time_seconds),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
timezone = self.upload_date.tzinfo
|
|
122
|
+
now = datetime.datetime.now(tz=timezone)
|
|
123
|
+
time_since_change = now - self.last_modified_date
|
|
124
|
+
|
|
125
|
+
if time_since_change <= processing_time:
|
|
126
|
+
return TargetStatuses.PROCESSING.value
|
|
127
|
+
|
|
128
|
+
return self._post_processing_status.value
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def _post_processing_target_rating(self) -> int:
|
|
132
|
+
"""The rating of the target after processing."""
|
|
133
|
+
return self.target_tracking_rater(image_content=self.image_value)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def tracking_rating(self) -> int:
|
|
137
|
+
"""Return the tracking rating of the target recognition image."""
|
|
138
|
+
pre_rating_time = datetime.timedelta(
|
|
139
|
+
# That this is half of the total processing time is unrealistic.
|
|
140
|
+
# In VWS it is not a constant percentage.
|
|
141
|
+
seconds=float(self.processing_time_seconds) / 2,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
timezone = self.upload_date.tzinfo
|
|
145
|
+
now = datetime.datetime.now(tz=timezone)
|
|
146
|
+
time_since_upload = now - self.upload_date
|
|
147
|
+
|
|
148
|
+
# The real VWS seems to give -1 for a short time while processing, then
|
|
149
|
+
# the real rating, even while it is still processing.
|
|
150
|
+
if time_since_upload <= pre_rating_time:
|
|
151
|
+
return -1
|
|
152
|
+
|
|
153
|
+
return self._post_processing_target_rating
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_dict(cls, target_dict: ImageTargetDict) -> Self:
|
|
157
|
+
"""Load a target from a dictionary."""
|
|
158
|
+
timezone = ZoneInfo(key="GMT")
|
|
159
|
+
name = target_dict["name"]
|
|
160
|
+
active_flag = target_dict["active_flag"]
|
|
161
|
+
width = target_dict["width"]
|
|
162
|
+
image_base64 = target_dict["image_base64"]
|
|
163
|
+
image_value = base64.b64decode(s=image_base64)
|
|
164
|
+
processing_time_seconds = target_dict["processing_time_seconds"]
|
|
165
|
+
application_metadata = target_dict["application_metadata"]
|
|
166
|
+
target_id = target_dict["target_id"]
|
|
167
|
+
delete_date_optional = target_dict["delete_date_optional"]
|
|
168
|
+
if delete_date_optional is None:
|
|
169
|
+
delete_date = None
|
|
170
|
+
else:
|
|
171
|
+
delete_date = datetime.datetime.fromisoformat(delete_date_optional)
|
|
172
|
+
delete_date = delete_date.replace(tzinfo=timezone)
|
|
173
|
+
|
|
174
|
+
last_modified_date = datetime.datetime.fromisoformat(
|
|
175
|
+
target_dict["last_modified_date"],
|
|
176
|
+
).replace(tzinfo=timezone)
|
|
177
|
+
upload_date = datetime.datetime.fromisoformat(
|
|
178
|
+
target_dict["upload_date"],
|
|
179
|
+
).replace(tzinfo=timezone)
|
|
180
|
+
|
|
181
|
+
target_tracking_rater = HardcodedTargetTrackingRater(
|
|
182
|
+
rating=target_dict["tracking_rating"],
|
|
183
|
+
)
|
|
184
|
+
return cls(
|
|
185
|
+
target_id=target_id,
|
|
186
|
+
name=name,
|
|
187
|
+
active_flag=active_flag,
|
|
188
|
+
width=width,
|
|
189
|
+
image_value=image_value,
|
|
190
|
+
processing_time_seconds=processing_time_seconds,
|
|
191
|
+
application_metadata=application_metadata,
|
|
192
|
+
delete_date=delete_date,
|
|
193
|
+
last_modified_date=last_modified_date,
|
|
194
|
+
upload_date=upload_date,
|
|
195
|
+
target_tracking_rater=target_tracking_rater,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def to_dict(self) -> ImageTargetDict:
|
|
199
|
+
"""Dump a target to a dictionary which can be loaded as JSON."""
|
|
200
|
+
delete_date: str | None = None
|
|
201
|
+
if self.delete_date:
|
|
202
|
+
delete_date = self.delete_date.isoformat()
|
|
203
|
+
|
|
204
|
+
image_base64 = base64.encodebytes(s=self.image_value).decode()
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"name": self.name,
|
|
208
|
+
"width": self.width,
|
|
209
|
+
"image_base64": image_base64,
|
|
210
|
+
"active_flag": self.active_flag,
|
|
211
|
+
"processing_time_seconds": float(self.processing_time_seconds),
|
|
212
|
+
"application_metadata": self.application_metadata,
|
|
213
|
+
"target_id": self.target_id,
|
|
214
|
+
"last_modified_date": self.last_modified_date.isoformat(),
|
|
215
|
+
"delete_date_optional": delete_date,
|
|
216
|
+
"upload_date": self.upload_date.isoformat(),
|
|
217
|
+
"tracking_rating": self.tracking_rating,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@beartype(conf=BeartypeConf(is_pep484_tower=True))
|
|
222
|
+
@dataclass(frozen=True, eq=True, kw_only=True)
|
|
223
|
+
class VuMarkTarget:
|
|
224
|
+
"""
|
|
225
|
+
A VuMark target as managed in
|
|
226
|
+
https://developer.vuforia.com/target-manager.
|
|
227
|
+
|
|
228
|
+
Unlike ImageTarget, VuMark targets do not require an image — they use a
|
|
229
|
+
VuMark template.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
name: str
|
|
233
|
+
processing_time_seconds: float = 0.0
|
|
234
|
+
target_id: str = field(default_factory=_random_hex)
|
|
235
|
+
last_modified_date: datetime.datetime = field(default_factory=_time_now)
|
|
236
|
+
upload_date: datetime.datetime = field(default_factory=_time_now)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def status(self) -> str:
|
|
240
|
+
"""Return the status of the target.
|
|
241
|
+
|
|
242
|
+
VuMark targets always succeed after processing.
|
|
243
|
+
"""
|
|
244
|
+
processing_time = datetime.timedelta(
|
|
245
|
+
seconds=float(self.processing_time_seconds),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
timezone = self.upload_date.tzinfo
|
|
249
|
+
now = datetime.datetime.now(tz=timezone)
|
|
250
|
+
time_since_change = now - self.last_modified_date
|
|
251
|
+
|
|
252
|
+
if time_since_change <= processing_time:
|
|
253
|
+
return TargetStatuses.PROCESSING.value
|
|
254
|
+
|
|
255
|
+
return TargetStatuses.SUCCESS.value
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def from_dict(cls, target_dict: VuMarkTargetDict) -> Self:
|
|
259
|
+
"""Load a VuMark target from a dictionary."""
|
|
260
|
+
timezone = ZoneInfo(key="GMT")
|
|
261
|
+
last_modified_date = datetime.datetime.fromisoformat(
|
|
262
|
+
target_dict["last_modified_date"],
|
|
263
|
+
).replace(tzinfo=timezone)
|
|
264
|
+
upload_date = datetime.datetime.fromisoformat(
|
|
265
|
+
target_dict["upload_date"],
|
|
266
|
+
).replace(tzinfo=timezone)
|
|
267
|
+
return cls(
|
|
268
|
+
target_id=target_dict["target_id"],
|
|
269
|
+
name=target_dict["name"],
|
|
270
|
+
processing_time_seconds=target_dict["processing_time_seconds"],
|
|
271
|
+
last_modified_date=last_modified_date,
|
|
272
|
+
upload_date=upload_date,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def to_dict(self) -> VuMarkTargetDict:
|
|
276
|
+
"""Dump a VuMark target to a dictionary which can be loaded as
|
|
277
|
+
JSON.
|
|
278
|
+
"""
|
|
279
|
+
return {
|
|
280
|
+
"target_id": self.target_id,
|
|
281
|
+
"name": self.name,
|
|
282
|
+
"processing_time_seconds": float(self.processing_time_seconds),
|
|
283
|
+
"last_modified_date": self.last_modified_date.isoformat(),
|
|
284
|
+
"upload_date": self.upload_date.isoformat(),
|
|
285
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""A fake implementation of a Vuforia target manager."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from beartype import beartype
|
|
6
|
+
|
|
7
|
+
from mock_vws.database import CloudDatabase, VuMarkDatabase
|
|
8
|
+
from mock_vws.model_target import ModelTargetDataset
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from mock_vws._database_matchers import AnyDatabase
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@beartype
|
|
15
|
+
class TargetManager:
|
|
16
|
+
"""
|
|
17
|
+
A target manager.
|
|
18
|
+
|
|
19
|
+
See https://developer.vuforia.com/target-manager.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
"""Create a target manager with no databases."""
|
|
24
|
+
self._cloud_databases: set[CloudDatabase] = set()
|
|
25
|
+
self._vumark_databases: set[VuMarkDatabase] = set()
|
|
26
|
+
self._model_target_datasets: dict[str, ModelTargetDataset] = {}
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def cloud_databases(self) -> set[CloudDatabase]:
|
|
30
|
+
"""All cloud databases."""
|
|
31
|
+
return set(self._cloud_databases)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def vumark_databases(self) -> set[VuMarkDatabase]:
|
|
35
|
+
"""All VuMark databases."""
|
|
36
|
+
return set(self._vumark_databases)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def model_target_datasets(self) -> dict[str, ModelTargetDataset]:
|
|
40
|
+
"""All Model Target datasets, keyed by UUID."""
|
|
41
|
+
return dict(self._model_target_datasets)
|
|
42
|
+
|
|
43
|
+
def remove_cloud_database(self, cloud_database: CloudDatabase) -> None:
|
|
44
|
+
"""Remove a cloud database.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
cloud_database: The cloud database to remove.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
KeyError: The cloud database is not in the target manager.
|
|
51
|
+
"""
|
|
52
|
+
self._cloud_databases = {
|
|
53
|
+
db for db in self._cloud_databases if db != cloud_database
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def remove_vumark_database(self, vumark_database: VuMarkDatabase) -> None:
|
|
57
|
+
"""Remove a VuMark database.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
vumark_database: The VuMark database to remove.
|
|
61
|
+
"""
|
|
62
|
+
self._vumark_databases = {
|
|
63
|
+
db for db in self._vumark_databases if db != vumark_database
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def add_model_target_dataset(
|
|
67
|
+
self,
|
|
68
|
+
model_target_dataset: ModelTargetDataset,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Add a Model Target dataset."""
|
|
71
|
+
self._model_target_datasets[model_target_dataset.uuid_] = (
|
|
72
|
+
model_target_dataset
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def remove_model_target_dataset(self, dataset_uuid: str) -> None:
|
|
76
|
+
"""Remove a Model Target dataset."""
|
|
77
|
+
del self._model_target_datasets[dataset_uuid]
|
|
78
|
+
|
|
79
|
+
def add_cloud_database(self, cloud_database: CloudDatabase) -> None:
|
|
80
|
+
"""Add a cloud database.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
cloud_database: The cloud database to add.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: One of the given cloud database keys matches a key for
|
|
87
|
+
an existing cloud database.
|
|
88
|
+
"""
|
|
89
|
+
message_fmt = (
|
|
90
|
+
"All {key_name}s must be unique. "
|
|
91
|
+
'There is already a database with the {key_name} "{value}".'
|
|
92
|
+
)
|
|
93
|
+
all_databases: list[AnyDatabase] = [
|
|
94
|
+
*self._cloud_databases,
|
|
95
|
+
*self._vumark_databases,
|
|
96
|
+
]
|
|
97
|
+
for existing_db in all_databases:
|
|
98
|
+
for existing, new, key_name in (
|
|
99
|
+
(
|
|
100
|
+
existing_db.server_access_key,
|
|
101
|
+
cloud_database.server_access_key,
|
|
102
|
+
"server access key",
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
existing_db.server_secret_key,
|
|
106
|
+
cloud_database.server_secret_key,
|
|
107
|
+
"server secret key",
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
existing_db.database_name,
|
|
111
|
+
cloud_database.database_name,
|
|
112
|
+
"name",
|
|
113
|
+
),
|
|
114
|
+
):
|
|
115
|
+
if existing == new:
|
|
116
|
+
message = message_fmt.format(key_name=key_name, value=new)
|
|
117
|
+
raise ValueError(message)
|
|
118
|
+
|
|
119
|
+
for existing_cloud_db in self._cloud_databases:
|
|
120
|
+
for existing, new, key_name in (
|
|
121
|
+
(
|
|
122
|
+
existing_cloud_db.client_access_key,
|
|
123
|
+
cloud_database.client_access_key,
|
|
124
|
+
"client access key",
|
|
125
|
+
),
|
|
126
|
+
(
|
|
127
|
+
existing_cloud_db.client_secret_key,
|
|
128
|
+
cloud_database.client_secret_key,
|
|
129
|
+
"client secret key",
|
|
130
|
+
),
|
|
131
|
+
):
|
|
132
|
+
if existing == new:
|
|
133
|
+
message = message_fmt.format(key_name=key_name, value=new)
|
|
134
|
+
raise ValueError(message)
|
|
135
|
+
|
|
136
|
+
self._cloud_databases = {*self._cloud_databases, cloud_database}
|
|
137
|
+
|
|
138
|
+
def add_vumark_database(self, vumark_database: VuMarkDatabase) -> None:
|
|
139
|
+
"""Add a VuMark database.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
vumark_database: The VuMark database to add.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: One of the given database keys matches a key for
|
|
146
|
+
an existing database.
|
|
147
|
+
"""
|
|
148
|
+
message_fmt = (
|
|
149
|
+
"All {key_name}s must be unique. "
|
|
150
|
+
'There is already a database with the {key_name} "{value}".'
|
|
151
|
+
)
|
|
152
|
+
all_databases: list[AnyDatabase] = [
|
|
153
|
+
*self._cloud_databases,
|
|
154
|
+
*self._vumark_databases,
|
|
155
|
+
]
|
|
156
|
+
for existing_db in all_databases:
|
|
157
|
+
for existing, new, key_name in (
|
|
158
|
+
(
|
|
159
|
+
existing_db.server_access_key,
|
|
160
|
+
vumark_database.server_access_key,
|
|
161
|
+
"server access key",
|
|
162
|
+
),
|
|
163
|
+
(
|
|
164
|
+
existing_db.server_secret_key,
|
|
165
|
+
vumark_database.server_secret_key,
|
|
166
|
+
"server secret key",
|
|
167
|
+
),
|
|
168
|
+
(
|
|
169
|
+
existing_db.database_name,
|
|
170
|
+
vumark_database.database_name,
|
|
171
|
+
"name",
|
|
172
|
+
),
|
|
173
|
+
):
|
|
174
|
+
if existing == new:
|
|
175
|
+
message = message_fmt.format(key_name=key_name, value=new)
|
|
176
|
+
raise ValueError(message)
|
|
177
|
+
|
|
178
|
+
self._vumark_databases = {*self._vumark_databases, vumark_database}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Raters for target quality."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import io
|
|
5
|
+
import math
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import torch
|
|
11
|
+
from beartype import beartype
|
|
12
|
+
from PIL import Image
|
|
13
|
+
from piq.brisque import brisque # pyright: ignore[reportMissingTypeStubs]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@functools.cache
|
|
17
|
+
@beartype
|
|
18
|
+
def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int:
|
|
19
|
+
"""Get a target tracking rating based on a BRISQUE score.
|
|
20
|
+
|
|
21
|
+
This is a rough approximation of the quality score used by Vuforia, but is
|
|
22
|
+
not accurate. For example, our "corrupted_image" rating is based on a
|
|
23
|
+
BRISQUE score of 0, but Vuforia's is 1.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
image_content: A target's image's content.
|
|
27
|
+
"""
|
|
28
|
+
image_file = io.BytesIO(initial_bytes=image_content)
|
|
29
|
+
with Image.open(fp=image_file) as image:
|
|
30
|
+
image_np = np.array(object=image, dtype=np.float32)
|
|
31
|
+
image_tensor = torch.tensor(data=image_np).float() / 255
|
|
32
|
+
image_tensor = image_tensor.view(
|
|
33
|
+
image.size[1],
|
|
34
|
+
image.size[0],
|
|
35
|
+
len(image.getbands()),
|
|
36
|
+
)
|
|
37
|
+
image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(dim=0)
|
|
38
|
+
try:
|
|
39
|
+
brisque_score = brisque(x=image_tensor, data_range=255)
|
|
40
|
+
except AssertionError, IndexError:
|
|
41
|
+
return 0
|
|
42
|
+
return math.ceil(int(brisque_score.item()) / 20)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class TargetTrackingRater(Protocol):
|
|
47
|
+
"""Protocol for a rater of target quality."""
|
|
48
|
+
|
|
49
|
+
def __call__(self, image_content: bytes) -> int:
|
|
50
|
+
"""The target tracking rating.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
image_content: A target's image's content.
|
|
54
|
+
"""
|
|
55
|
+
# We disable a pylint warning here because the ellipsis is required
|
|
56
|
+
# for pyright to recognize this as a protocol.
|
|
57
|
+
... # pylint: disable=unnecessary-ellipsis
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@beartype
|
|
61
|
+
class RandomTargetTrackingRater:
|
|
62
|
+
"""A rater which returns a random number."""
|
|
63
|
+
|
|
64
|
+
def __call__(self, image_content: bytes) -> int:
|
|
65
|
+
"""A random target tracking rating.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
image_content: A target's image's content.
|
|
69
|
+
"""
|
|
70
|
+
del image_content
|
|
71
|
+
return secrets.randbelow(exclusive_upper_bound=6)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@beartype
|
|
75
|
+
class HardcodedTargetTrackingRater:
|
|
76
|
+
"""A rater which returns a hardcoded number."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, rating: int) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Args:
|
|
81
|
+
rating: The rating to return.
|
|
82
|
+
"""
|
|
83
|
+
self._rating = rating
|
|
84
|
+
|
|
85
|
+
def __call__(self, image_content: bytes) -> int:
|
|
86
|
+
"""A random target tracking rating.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
image_content: A target's image's content.
|
|
90
|
+
"""
|
|
91
|
+
del image_content
|
|
92
|
+
return self._rating
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@beartype
|
|
96
|
+
class BrisqueTargetTrackingRater:
|
|
97
|
+
"""A rater which returns a rating based on a BRISQUE score."""
|
|
98
|
+
|
|
99
|
+
def __call__(self, image_content: bytes) -> int:
|
|
100
|
+
"""A rating based on a BRISQUE score.
|
|
101
|
+
|
|
102
|
+
This is a rough approximation of the quality score used by Vuforia, but
|
|
103
|
+
is not accurate. For example, our "corrupted_image" fixture is rated as
|
|
104
|
+
-2 by Vuforia, but is rated as 0 by this function.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
image_content: A target's image's content.
|
|
108
|
+
"""
|
|
109
|
+
return _get_brisque_target_tracking_rating(image_content=image_content)
|