ImagePRO-Python 1.4.0__py3-none-any.whl → 1.6.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.
- ImagePRO/__init__.py +5 -2
- ImagePRO/batch/__init__.py +14 -0
- ImagePRO/batch/batch.py +529 -0
- ImagePRO/human_analysis/body_analysis/body_pose_estimation.py +10 -4
- ImagePRO/human_analysis/body_analysis/hand_tracking.py +13 -7
- ImagePRO/human_analysis/face_analysis/eye_status_analysis.py +10 -5
- ImagePRO/human_analysis/face_analysis/face_comparison.py +54 -61
- ImagePRO/human_analysis/face_analysis/face_detection.py +3 -2
- ImagePRO/human_analysis/face_analysis/face_mesh_analysis.py +20 -9
- ImagePRO/human_analysis/face_analysis/head_pose_estimation.py +20 -7
- ImagePRO/object_analysis/object_detection.py +12 -3
- ImagePRO/pipeline/pipeline.py +3 -1
- ImagePRO/pre_processing/blur.py +13 -6
- ImagePRO/pre_processing/contrast.py +31 -13
- ImagePRO/pre_processing/crop.py +6 -3
- ImagePRO/pre_processing/dataset_generator.py +93 -26
- ImagePRO/pre_processing/histogram.py +16 -11
- ImagePRO/pre_processing/resize.py +4 -1
- ImagePRO/pre_processing/rotate.py +2 -2
- ImagePRO/pre_processing/sharpen.py +20 -21
- ImagePRO/utils/image.py +11 -4
- ImagePRO/utils/result.py +20 -13
- {imagepro_python-1.4.0.dist-info → imagepro_python-1.6.0.dist-info}/METADATA +16 -2
- imagepro_python-1.6.0.dist-info/RECORD +35 -0
- imagepro_python-1.4.0.dist-info/RECORD +0 -33
- {imagepro_python-1.4.0.dist-info → imagepro_python-1.6.0.dist-info}/WHEEL +0 -0
- {imagepro_python-1.4.0.dist-info → imagepro_python-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {imagepro_python-1.4.0.dist-info → imagepro_python-1.6.0.dist-info}/top_level.txt +0 -0
ImagePRO/__init__.py
CHANGED
|
@@ -13,10 +13,11 @@ Features:
|
|
|
13
13
|
- Human analysis: face mesh, pose estimation, hand tracking
|
|
14
14
|
- Object detection with YOLO models
|
|
15
15
|
- Reusable pipelines for chaining operations
|
|
16
|
+
- Batch processing: run any operation over many images at once
|
|
16
17
|
- Real-time processing capabilities
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
|
-
__version__ = "1.
|
|
20
|
+
__version__ = "1.6.0"
|
|
20
21
|
__author__ = "Parsa Safaie"
|
|
21
22
|
__email__ = "parsasafaie.2568@proton.me"
|
|
22
23
|
|
|
@@ -26,11 +27,13 @@ from . import pre_processing
|
|
|
26
27
|
from . import human_analysis
|
|
27
28
|
from . import object_analysis
|
|
28
29
|
from . import pipeline
|
|
30
|
+
from . import batch
|
|
29
31
|
|
|
30
32
|
__all__ = [
|
|
31
33
|
"utils",
|
|
32
34
|
"pre_processing",
|
|
33
35
|
"human_analysis",
|
|
34
36
|
"object_analysis",
|
|
35
|
-
"pipeline"
|
|
37
|
+
"pipeline",
|
|
38
|
+
"batch"
|
|
36
39
|
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Batch Module
|
|
2
|
+
# Runs a single operation over many inputs at once
|
|
3
|
+
#
|
|
4
|
+
# Uses only base dependencies (numpy); the operation handed to
|
|
5
|
+
# batch_process may come from any module, including those with optional
|
|
6
|
+
# backends (mediapipe, ultralytics, insightface).
|
|
7
|
+
|
|
8
|
+
from .batch import BatchFailure, BatchResult, batch_process
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BatchFailure",
|
|
12
|
+
"BatchResult",
|
|
13
|
+
"batch_process"
|
|
14
|
+
]
|
ImagePRO/batch/batch.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Batch processing for ImagePRO: run one operation over many images.
|
|
2
|
+
|
|
3
|
+
Exposes ``batch_process`` (the runner), ``BatchResult`` (the aggregate
|
|
4
|
+
return value), and ``BatchFailure`` (one input that could not be
|
|
5
|
+
processed). The module itself needs only the base dependencies; the
|
|
6
|
+
operation handed to ``batch_process`` may come from any module, including
|
|
7
|
+
those with optional backends.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import glob
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Optional, Union
|
|
19
|
+
|
|
20
|
+
# Add src directory to path for absolute imports
|
|
21
|
+
_file_path = Path(__file__).resolve()
|
|
22
|
+
_src_path = _file_path.parents[2] # Go up to src directory
|
|
23
|
+
if str(_src_path) not in sys.path:
|
|
24
|
+
sys.path.insert(0, str(_src_path))
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from ImagePRO.pipeline import Pipeline, Step
|
|
29
|
+
from ImagePRO.utils.image import Image
|
|
30
|
+
from ImagePRO.utils.result import Result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Constants
|
|
34
|
+
IMAGE_EXTENSIONS = frozenset({
|
|
35
|
+
".bmp", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp",
|
|
36
|
+
})
|
|
37
|
+
GLOB_MAGIC_CHARS = "*?["
|
|
38
|
+
ON_ERROR_MODES = ("raise", "collect")
|
|
39
|
+
DEFAULT_ON_ERROR = "raise"
|
|
40
|
+
DEFAULT_WORKERS = 1
|
|
41
|
+
DEFAULT_RECURSIVE = False
|
|
42
|
+
|
|
43
|
+
InputLike = Union[Image, np.ndarray, str, Path]
|
|
44
|
+
InputsLike = Union[InputLike, list[InputLike], tuple[InputLike, ...]]
|
|
45
|
+
Operation = Callable[..., Result]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, eq=False)
|
|
49
|
+
class BatchFailure:
|
|
50
|
+
"""
|
|
51
|
+
One input that could not be processed.
|
|
52
|
+
|
|
53
|
+
Only produced when ``batch_process`` runs with ``on_error="collect"``;
|
|
54
|
+
the other inputs keep processing and the batch still returns.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
index (int):
|
|
58
|
+
Position of the failed input in the original input list (or in
|
|
59
|
+
the directory/glob listing order).
|
|
60
|
+
source (Any):
|
|
61
|
+
The original input item: a file path (``pathlib.Path``), an
|
|
62
|
+
``Image``, or a ``numpy.ndarray``.
|
|
63
|
+
error_type (str):
|
|
64
|
+
Name of the exception class (e.g. "ValueError").
|
|
65
|
+
error (str):
|
|
66
|
+
The exception message.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
index: int
|
|
70
|
+
source: Any
|
|
71
|
+
error_type: str
|
|
72
|
+
error: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class BatchResult:
|
|
77
|
+
"""
|
|
78
|
+
Aggregate result of a batch run.
|
|
79
|
+
|
|
80
|
+
Holds one entry per input, in input order, plus the batch-level
|
|
81
|
+
metadata (counts, configuration, timing).
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
results (list[Optional[Result]]):
|
|
85
|
+
One slot per input, in input order. Successful inputs hold
|
|
86
|
+
their ``Result`` object (exactly what the operation returned
|
|
87
|
+
for that image). Slots are ``None`` for failed inputs — only
|
|
88
|
+
possible with ``on_error="collect"``, since the default
|
|
89
|
+
"raise" mode aborts the batch on the first failure.
|
|
90
|
+
failures (list[BatchFailure]):
|
|
91
|
+
Failed inputs, ordered by index. Empty unless
|
|
92
|
+
``on_error="collect"``.
|
|
93
|
+
meta (dict[str, Any]):
|
|
94
|
+
Batch metadata: "operation", "total", "succeeded", "failed",
|
|
95
|
+
"on_error", "workers", "recursive", "duration_seconds", and
|
|
96
|
+
"output_dir" (``None`` when results were kept in memory only).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
results: list[Optional[Result]] = field(default_factory=list)
|
|
100
|
+
failures: list[BatchFailure] = field(default_factory=list)
|
|
101
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def batch_process(
|
|
105
|
+
inputs: InputsLike,
|
|
106
|
+
operation: Operation,
|
|
107
|
+
*,
|
|
108
|
+
on_error: str = DEFAULT_ON_ERROR,
|
|
109
|
+
output_dir: Optional[str | Path] = None,
|
|
110
|
+
recursive: bool = DEFAULT_RECURSIVE,
|
|
111
|
+
workers: int = DEFAULT_WORKERS,
|
|
112
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
|
113
|
+
**kwargs: Any,
|
|
114
|
+
) -> BatchResult:
|
|
115
|
+
"""Run one operation over many images and collect the results.
|
|
116
|
+
|
|
117
|
+
Collects every input — a directory, a glob pattern, or a list of file
|
|
118
|
+
paths, Image instances and NumPy arrays — then runs ``operation`` on
|
|
119
|
+
each one, in input order. Inputs are loaded one at a time, so a folder
|
|
120
|
+
with thousands of images never has to fit in memory at once.
|
|
121
|
+
|
|
122
|
+
The operation can be any ImagePRO function (which takes an ``Image``
|
|
123
|
+
and returns a ``Result``), a ``Step``, a ``Pipeline``, or any custom
|
|
124
|
+
callable following the same convention. Returning a plain
|
|
125
|
+
``numpy.ndarray`` is accepted and wrapped in a ``Result``. Keyword
|
|
126
|
+
arguments given here are forwarded to the operation on every input,
|
|
127
|
+
exactly like ``Step``.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
inputs (list | tuple | str | Path | Image | np.ndarray):
|
|
131
|
+
What to process. Accepts:
|
|
132
|
+
- A directory path: every image file inside it (case-
|
|
133
|
+
insensitive extension match against ``.bmp .jpeg .jpg .png
|
|
134
|
+
.tif .tiff .webp``), sorted by path. Sub-directories are
|
|
135
|
+
included only with ``recursive=True``.
|
|
136
|
+
- A glob pattern (containing ``*``, ``?`` or ``[``): matching
|
|
137
|
+
image files, sorted by path.
|
|
138
|
+
- A single file path, ``Image``, or NumPy array: that one
|
|
139
|
+
input.
|
|
140
|
+
- A list/tuple mixing file paths, ``Image`` instances and
|
|
141
|
+
NumPy arrays.
|
|
142
|
+
operation (Callable):
|
|
143
|
+
The callable to run on each input. Pre-loaded detectors can be
|
|
144
|
+
injected through it — e.g. ``Step(detect_objects, model=my_model)``
|
|
145
|
+
— which matters a lot for heavy backends: without a pre-loaded
|
|
146
|
+
model, a fresh detector is constructed for every image.
|
|
147
|
+
on_error (str, optional):
|
|
148
|
+
What to do when an input fails. "raise" (default) re-raises
|
|
149
|
+
the operation's own error unchanged, like every other ImagePRO
|
|
150
|
+
function. "collect" keeps processing the remaining inputs and
|
|
151
|
+
records the failure in ``BatchResult.failures``. Either way,
|
|
152
|
+
an ``ImportError`` for a missing optional backend always
|
|
153
|
+
propagates: it is a setup problem, not a bad input.
|
|
154
|
+
output_dir (str | Path, optional):
|
|
155
|
+
If given, each successful result's image is saved there: the
|
|
156
|
+
output name mirrors the input name (relative sub-paths are
|
|
157
|
+
preserved for directory inputs) and name clashes get a numeric
|
|
158
|
+
suffix (``a.png``, ``a_1.png``, ...). Results without an image
|
|
159
|
+
(data-only results) are not saved. Defaults to None (keep
|
|
160
|
+
results in memory only).
|
|
161
|
+
recursive (bool, optional):
|
|
162
|
+
Include sub-directories when ``inputs`` is a directory.
|
|
163
|
+
Defaults to False.
|
|
164
|
+
workers (int, optional):
|
|
165
|
+
Number of worker threads. Defaults to 1 (fully sequential,
|
|
166
|
+
deterministic order). With more than one, inputs are processed
|
|
167
|
+
concurrently but ``BatchResult.results`` still follows input
|
|
168
|
+
order. Detector objects injected into the operation (MediaPipe,
|
|
169
|
+
YOLO, InsightFace) are not guaranteed thread-safe: keep
|
|
170
|
+
``workers=1`` unless the operation is pure image processing
|
|
171
|
+
(e.g. OpenCV filters, which release the GIL).
|
|
172
|
+
progress_callback (Callable[[int, int], None], optional):
|
|
173
|
+
Called after each input completes with ``(completed, total)``.
|
|
174
|
+
Always called from the calling thread, never from a worker.
|
|
175
|
+
Defaults to None.
|
|
176
|
+
**kwargs (Any):
|
|
177
|
+
Forwarded to the operation on every input, like ``Step``.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
BatchResult: One entry per input (``results``, input order),
|
|
181
|
+
``failures`` (empty in "raise" mode), and batch ``meta``.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
TypeError: If ``inputs``, ``operation``, ``output_dir``,
|
|
185
|
+
``progress_callback`` or ``recursive`` has an invalid type.
|
|
186
|
+
ValueError: If ``on_error`` or ``workers`` is invalid, if
|
|
187
|
+
``output_dir`` points at an existing non-directory, or if
|
|
188
|
+
keyword arguments are combined with a ``Step``/``Pipeline``
|
|
189
|
+
operation (attach them to the Step instead).
|
|
190
|
+
FileNotFoundError: If a single input path does not exist.
|
|
191
|
+
TypeError/ValueError: Propagated unchanged from the operation or
|
|
192
|
+
from input normalization when ``on_error="raise"``.
|
|
193
|
+
|
|
194
|
+
Note:
|
|
195
|
+
The batch options (``on_error``, ``output_dir``, ``recursive``,
|
|
196
|
+
``workers``, ``progress_callback``) are reserved: to pass a
|
|
197
|
+
parameter with one of these names to the operation itself, wrap
|
|
198
|
+
the operation in a ``Step`` or ``functools.partial`` instead of
|
|
199
|
+
passing it here.
|
|
200
|
+
|
|
201
|
+
Inputs are read one at a time, but the returned ``BatchResult``
|
|
202
|
+
keeps every result, and each result keeps its input in
|
|
203
|
+
``meta["source"]``. A batch therefore holds about the whole
|
|
204
|
+
dataset (inputs plus outputs) in memory; ``output_dir`` writes
|
|
205
|
+
images to disk without releasing them. For very large inputs, call
|
|
206
|
+
``batch_process`` once per chunk of the input list so each
|
|
207
|
+
``BatchResult`` can be dropped before the next one is built.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
>>> from ImagePRO.pre_processing.blur import apply_gaussian_blur
|
|
211
|
+
>>> batch = batch_process("photos/", apply_gaussian_blur, kernel_size=(9, 9))
|
|
212
|
+
>>> batch.meta["succeeded"]
|
|
213
|
+
2
|
|
214
|
+
>>> batch.results[0].save_as_img("first_blurred.png")
|
|
215
|
+
"""
|
|
216
|
+
# Validate the batch configuration before touching any input, so an
|
|
217
|
+
# invalid call never leaves partial state (e.g. a created folder)
|
|
218
|
+
# behind.
|
|
219
|
+
if not callable(operation):
|
|
220
|
+
raise TypeError(
|
|
221
|
+
"'operation' must be a callable that accepts an Image as its "
|
|
222
|
+
"first positional argument (an ImagePRO function, a Step, or a "
|
|
223
|
+
"Pipeline)."
|
|
224
|
+
)
|
|
225
|
+
if on_error not in ON_ERROR_MODES:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"'on_error' must be one of {list(ON_ERROR_MODES)}, got {on_error!r}."
|
|
228
|
+
)
|
|
229
|
+
if not isinstance(workers, int) or isinstance(workers, bool) or workers < 1:
|
|
230
|
+
raise ValueError("'workers' must be a positive integer.")
|
|
231
|
+
if not isinstance(recursive, bool):
|
|
232
|
+
raise TypeError("'recursive' must be a boolean.")
|
|
233
|
+
if output_dir is not None:
|
|
234
|
+
if not isinstance(output_dir, (str, Path)):
|
|
235
|
+
raise TypeError("'output_dir' must be a string or pathlib.Path.")
|
|
236
|
+
out_root = Path(output_dir)
|
|
237
|
+
if out_root.exists() and not out_root.is_dir():
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"'output_dir' exists and is not a directory: {out_root}"
|
|
240
|
+
)
|
|
241
|
+
out_root.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
else:
|
|
243
|
+
out_root = None
|
|
244
|
+
if progress_callback is not None and not callable(progress_callback):
|
|
245
|
+
raise TypeError("'progress_callback' must be a callable.")
|
|
246
|
+
if kwargs and isinstance(operation, (Step, Pipeline)):
|
|
247
|
+
raise ValueError(
|
|
248
|
+
"Keyword arguments cannot be combined with a Step or Pipeline "
|
|
249
|
+
"operation; their keyword arguments are already fixed at "
|
|
250
|
+
"construction time. Attach them to the Step instead, e.g. "
|
|
251
|
+
"Step(resize_image, new_size=(800, 600))."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Collect all inputs up front; paths are only read during processing,
|
|
255
|
+
# one at a time. Output paths are pre-assigned here (rather than per
|
|
256
|
+
# worker) so collision suffixes stay deterministic and thread-safe.
|
|
257
|
+
sources = _collect_sources(inputs, recursive=recursive)
|
|
258
|
+
total = len(sources)
|
|
259
|
+
taken_names: set[Path] = set()
|
|
260
|
+
if out_root is not None:
|
|
261
|
+
output_paths = [
|
|
262
|
+
_assign_output_path(out_root, name, taken_names)
|
|
263
|
+
for _, name in sources
|
|
264
|
+
]
|
|
265
|
+
else:
|
|
266
|
+
output_paths = [None] * total
|
|
267
|
+
|
|
268
|
+
operation_name = getattr(operation, "__name__", type(operation).__name__)
|
|
269
|
+
|
|
270
|
+
def run_item(index: int) -> tuple[int, Optional[Result], Optional[Exception]]:
|
|
271
|
+
"""Process one input; returns (index, result, error)."""
|
|
272
|
+
source = sources[index][0]
|
|
273
|
+
out_path = output_paths[index]
|
|
274
|
+
try:
|
|
275
|
+
image = _as_image(source)
|
|
276
|
+
output = operation(image, **kwargs)
|
|
277
|
+
if isinstance(output, Result):
|
|
278
|
+
result = output
|
|
279
|
+
elif isinstance(output, np.ndarray):
|
|
280
|
+
result = Result(image=output, meta={"operation": operation_name})
|
|
281
|
+
else:
|
|
282
|
+
raise TypeError(
|
|
283
|
+
f"The operation returned {type(output).__name__}; batch "
|
|
284
|
+
"operations must return a Result object or a numpy.ndarray."
|
|
285
|
+
)
|
|
286
|
+
if out_path is not None and result.image is not None:
|
|
287
|
+
result.save_as_img(out_path)
|
|
288
|
+
return index, result, None
|
|
289
|
+
except ImportError:
|
|
290
|
+
# A missing optional backend is a setup problem, not a bad
|
|
291
|
+
# input: collect mode must not swallow it N times over.
|
|
292
|
+
raise
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
return index, None, exc
|
|
295
|
+
|
|
296
|
+
results: list[Optional[Result]] = [None] * total
|
|
297
|
+
failures: list[BatchFailure] = []
|
|
298
|
+
completed = 0
|
|
299
|
+
started = time.monotonic()
|
|
300
|
+
|
|
301
|
+
def record_item(index: int, result: Optional[Result], error: Optional[Exception]) -> None:
|
|
302
|
+
"""Record one finished item and report progress from this thread."""
|
|
303
|
+
nonlocal completed
|
|
304
|
+
if error is not None:
|
|
305
|
+
failures.append(BatchFailure(
|
|
306
|
+
index=index,
|
|
307
|
+
source=sources[index][0],
|
|
308
|
+
error_type=type(error).__name__,
|
|
309
|
+
error=str(error),
|
|
310
|
+
))
|
|
311
|
+
else:
|
|
312
|
+
results[index] = result
|
|
313
|
+
completed += 1
|
|
314
|
+
if progress_callback is not None:
|
|
315
|
+
progress_callback(completed, total)
|
|
316
|
+
|
|
317
|
+
if on_error == "raise":
|
|
318
|
+
if workers > 1 and total > 1:
|
|
319
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
320
|
+
futures = {executor.submit(run_item, index): index for index in range(total)}
|
|
321
|
+
try:
|
|
322
|
+
for future in as_completed(futures):
|
|
323
|
+
index, result, error = future.result()
|
|
324
|
+
if error is not None:
|
|
325
|
+
raise error
|
|
326
|
+
record_item(index, result, None)
|
|
327
|
+
finally:
|
|
328
|
+
for future in futures:
|
|
329
|
+
future.cancel()
|
|
330
|
+
else:
|
|
331
|
+
for index in range(total):
|
|
332
|
+
_, result, error = run_item(index)
|
|
333
|
+
if error is not None:
|
|
334
|
+
raise error
|
|
335
|
+
record_item(index, result, None)
|
|
336
|
+
else: # on_error == "collect"
|
|
337
|
+
if workers > 1 and total > 1:
|
|
338
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
339
|
+
futures = {executor.submit(run_item, index): index for index in range(total)}
|
|
340
|
+
for future in as_completed(futures):
|
|
341
|
+
index, result, error = future.result()
|
|
342
|
+
record_item(index, result, error)
|
|
343
|
+
else:
|
|
344
|
+
for index in range(total):
|
|
345
|
+
_, result, error = run_item(index)
|
|
346
|
+
record_item(index, result, error)
|
|
347
|
+
|
|
348
|
+
# as_completed order is not deterministic; sort for reproducible
|
|
349
|
+
# failure listings regardless of the worker count.
|
|
350
|
+
failures.sort(key=lambda failure: failure.index)
|
|
351
|
+
|
|
352
|
+
meta = {
|
|
353
|
+
"operation": operation_name,
|
|
354
|
+
"total": total,
|
|
355
|
+
"succeeded": sum(1 for result in results if result is not None),
|
|
356
|
+
"failed": len(failures),
|
|
357
|
+
"on_error": on_error,
|
|
358
|
+
"workers": workers,
|
|
359
|
+
"recursive": recursive,
|
|
360
|
+
"duration_seconds": round(time.monotonic() - started, 6),
|
|
361
|
+
"output_dir": str(out_root) if out_root is not None else None,
|
|
362
|
+
}
|
|
363
|
+
return BatchResult(results=results, failures=failures, meta=meta)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _as_image(source: Any) -> Image:
|
|
367
|
+
"""
|
|
368
|
+
Normalize one collected input into an Image.
|
|
369
|
+
|
|
370
|
+
Mirrors the input normalization of ``Pipeline.run``: file paths are
|
|
371
|
+
loaded from disk and NumPy arrays are wrapped, assuming BGR for
|
|
372
|
+
3-channel and GRAY for 2D arrays.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
source (Any): A ``pathlib.Path``, ``Image``, or NumPy array.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Image: The normalized input.
|
|
379
|
+
"""
|
|
380
|
+
if isinstance(source, Image):
|
|
381
|
+
return source
|
|
382
|
+
if isinstance(source, np.ndarray):
|
|
383
|
+
colorspace = "GRAY" if source.ndim == 2 else "BGR"
|
|
384
|
+
return Image.from_array(source, colorspace=colorspace)
|
|
385
|
+
return Image.from_path(source)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _collect_sources(inputs: InputsLike, *, recursive: bool) -> list[tuple[Any, Any]]:
|
|
389
|
+
"""
|
|
390
|
+
Turn the ``inputs`` argument into (source, output name) pairs.
|
|
391
|
+
|
|
392
|
+
Every pair is one work item: ``source`` is a file path, an ``Image``
|
|
393
|
+
or a NumPy array handed to the operation; ``output name`` is the name
|
|
394
|
+
used for saving under ``output_dir`` (a relative path for directory
|
|
395
|
+
inputs, a file name for list inputs, ``image_<index>`` for arrays).
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
inputs (InputsLike): The raw ``inputs`` argument.
|
|
399
|
+
recursive (bool): Whether directory scans descend into
|
|
400
|
+
sub-directories.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
list[tuple[Any, Any]]: One (source, output name) pair per input,
|
|
404
|
+
in input order.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
TypeError: If ``inputs`` or one of its items has an invalid type.
|
|
408
|
+
FileNotFoundError: If a single input path does not exist.
|
|
409
|
+
"""
|
|
410
|
+
if isinstance(inputs, (Image, np.ndarray)):
|
|
411
|
+
return [(inputs, "image_0")]
|
|
412
|
+
if isinstance(inputs, (list, tuple)):
|
|
413
|
+
sources: list[tuple[Any, Any]] = []
|
|
414
|
+
for index, item in enumerate(inputs):
|
|
415
|
+
if isinstance(item, (str, Path)):
|
|
416
|
+
sources.append((Path(item), Path(item).name))
|
|
417
|
+
elif isinstance(item, (Image, np.ndarray)):
|
|
418
|
+
sources.append((item, f"image_{index}"))
|
|
419
|
+
else:
|
|
420
|
+
raise TypeError(
|
|
421
|
+
"Every input must be a file path (str or pathlib.Path), "
|
|
422
|
+
"an Image instance, or a numpy.ndarray; got "
|
|
423
|
+
f"{type(item).__name__} at index {index}."
|
|
424
|
+
)
|
|
425
|
+
return sources
|
|
426
|
+
if isinstance(inputs, (str, Path)):
|
|
427
|
+
return _collect_from_path(inputs, recursive=recursive)
|
|
428
|
+
raise TypeError(
|
|
429
|
+
"'inputs' must be a list/tuple of images, a directory path, a glob "
|
|
430
|
+
"pattern, or a single Image or numpy.ndarray; got "
|
|
431
|
+
f"{type(inputs).__name__}."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _collect_from_path(value: str | Path, *, recursive: bool) -> list[tuple[Path, Any]]:
|
|
436
|
+
"""
|
|
437
|
+
Expand a single string/path input into its work items.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
value (str | Path): A directory path, a glob pattern, or a file
|
|
441
|
+
path.
|
|
442
|
+
recursive (bool): Whether directory scans descend into
|
|
443
|
+
sub-directories.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
list[tuple[Path, Any]]: One (source, output name) pair per match,
|
|
447
|
+
sorted by path.
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
FileNotFoundError: If the path is neither a glob pattern nor an
|
|
451
|
+
existing file or directory.
|
|
452
|
+
"""
|
|
453
|
+
if any(char in str(value) for char in GLOB_MAGIC_CHARS):
|
|
454
|
+
return [(match, match.name) for match in _expand_glob(str(value))]
|
|
455
|
+
path = Path(value)
|
|
456
|
+
if path.is_dir():
|
|
457
|
+
return [
|
|
458
|
+
(match, match.relative_to(path))
|
|
459
|
+
for match in _scan_directory(path, recursive=recursive)
|
|
460
|
+
]
|
|
461
|
+
if path.is_file():
|
|
462
|
+
return [(path, path.name)]
|
|
463
|
+
raise FileNotFoundError(f"Input path does not exist: {path}")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _scan_directory(root: Path, *, recursive: bool) -> list[Path]:
|
|
467
|
+
"""
|
|
468
|
+
List the image files inside a directory, sorted by path.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
root (Path): Directory to scan.
|
|
472
|
+
recursive (bool): Whether to descend into sub-directories.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
list[Path]: Matching files, sorted for deterministic order.
|
|
476
|
+
"""
|
|
477
|
+
pattern = "**/*" if recursive else "*"
|
|
478
|
+
return sorted(
|
|
479
|
+
match for match in root.glob(pattern)
|
|
480
|
+
if match.is_file() and match.suffix.lower() in IMAGE_EXTENSIONS
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _expand_glob(pattern: str) -> list[Path]:
|
|
485
|
+
"""
|
|
486
|
+
Expand a glob pattern into its image file matches, sorted by path.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
pattern (str): Glob pattern (``**`` is supported).
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
list[Path]: Matching files, sorted for deterministic order.
|
|
493
|
+
"""
|
|
494
|
+
return sorted(
|
|
495
|
+
match for match in (Path(found) for found in glob.glob(pattern, recursive=True))
|
|
496
|
+
if match.is_file() and match.suffix.lower() in IMAGE_EXTENSIONS
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _assign_output_path(base_dir: Path, name: Any, taken: set[Path]) -> Path:
|
|
501
|
+
"""
|
|
502
|
+
Return a collision-free output path under ``base_dir`` for ``name``.
|
|
503
|
+
|
|
504
|
+
A relative name keeps its sub-paths (``sub/a.png``), so directory
|
|
505
|
+
inputs are mirrored. When two inputs would write to the same path, the
|
|
506
|
+
later one gets a numeric suffix (``a.png``, ``a_1.png``), matching the
|
|
507
|
+
convention of ``Result.save_as_img``.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
base_dir (Path): Output root directory.
|
|
511
|
+
name (Any): Relative output name (str or Path) for this input.
|
|
512
|
+
taken (set[Path]): Paths already assigned; updated in place.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Path: The output path for this input.
|
|
516
|
+
"""
|
|
517
|
+
candidate = base_dir.joinpath(*Path(name).parts)
|
|
518
|
+
if candidate not in taken:
|
|
519
|
+
taken.add(candidate)
|
|
520
|
+
return candidate
|
|
521
|
+
stem, suffix = candidate.stem, candidate.suffix
|
|
522
|
+
parent = candidate.parent
|
|
523
|
+
counter = 1
|
|
524
|
+
while True:
|
|
525
|
+
alternative = parent / f"{stem}_{counter}{suffix}"
|
|
526
|
+
if alternative not in taken:
|
|
527
|
+
taken.add(alternative)
|
|
528
|
+
return alternative
|
|
529
|
+
counter += 1
|
|
@@ -23,6 +23,11 @@ TOTAL_LANDMARKS = 33
|
|
|
23
23
|
DEFAULT_CONFIDENCE = 0.7
|
|
24
24
|
LANDMARK_RADIUS = 3
|
|
25
25
|
LANDMARK_COLOR = (0, 0, 255) # Red color for landmarks
|
|
26
|
+
# Backend-agnostic capture: the DSHOW backend only exists on Windows, so
|
|
27
|
+
# the default leaves the choice to OpenCV. Tests inject a fake through
|
|
28
|
+
# this hook.
|
|
29
|
+
DEFAULT_CAMERA_BACKEND = None
|
|
30
|
+
|
|
26
31
|
|
|
27
32
|
def detect_body_pose(
|
|
28
33
|
image: Image,
|
|
@@ -62,12 +67,13 @@ def detect_body_pose(
|
|
|
62
67
|
if not isinstance(image, Image):
|
|
63
68
|
raise TypeError("'image' must be an Image instance")
|
|
64
69
|
|
|
65
|
-
if
|
|
70
|
+
if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
|
|
71
|
+
or not (0.0 <= min_confidence <= 1.0):
|
|
66
72
|
raise ValueError("'min_confidence' must be between 0.0 and 1.0")
|
|
67
73
|
|
|
68
74
|
if landmarks_idx is not None and (
|
|
69
75
|
not isinstance(landmarks_idx, list)
|
|
70
|
-
or not all(isinstance(i, int) for i in landmarks_idx)
|
|
76
|
+
or not all(isinstance(i, int) and not isinstance(i, bool) for i in landmarks_idx)
|
|
71
77
|
):
|
|
72
78
|
raise TypeError("'landmarks_idx' must be a list of integers")
|
|
73
79
|
|
|
@@ -153,8 +159,8 @@ def detect_body_pose_live() -> None:
|
|
|
153
159
|
'detection. Install it with: pip install "ImagePRO-Python[mediapipe]"'
|
|
154
160
|
) from err
|
|
155
161
|
|
|
156
|
-
# Initialize camera
|
|
157
|
-
cap = cv2.VideoCapture(0,
|
|
162
|
+
# Initialize camera (backend-agnostic; see DEFAULT_CAMERA_BACKEND)
|
|
163
|
+
cap = cv2.VideoCapture(0, DEFAULT_CAMERA_BACKEND)
|
|
158
164
|
if not cap.isOpened():
|
|
159
165
|
raise RuntimeError("Cannot access webcam")
|
|
160
166
|
|
|
@@ -24,6 +24,10 @@ DEFAULT_MIN_CONFIDENCE = 0.7
|
|
|
24
24
|
TOTAL_HAND_LANDMARKS = 21
|
|
25
25
|
LANDMARK_RADIUS = 3
|
|
26
26
|
LANDMARK_COLOR = (0, 0, 255) # Red color for landmarks
|
|
27
|
+
# Backend-agnostic capture: the DSHOW backend only exists on Windows, so
|
|
28
|
+
# the default leaves the choice to OpenCV. Tests inject a fake through
|
|
29
|
+
# this hook.
|
|
30
|
+
DEFAULT_CAMERA_BACKEND = None
|
|
27
31
|
|
|
28
32
|
|
|
29
33
|
def detect_hands(
|
|
@@ -69,15 +73,16 @@ def detect_hands(
|
|
|
69
73
|
if not isinstance(image, Image):
|
|
70
74
|
raise TypeError("'image' must be an Image instance")
|
|
71
75
|
|
|
72
|
-
if not isinstance(max_hands, int) or max_hands <= 0:
|
|
76
|
+
if not isinstance(max_hands, int) or isinstance(max_hands, bool) or max_hands <= 0:
|
|
73
77
|
raise ValueError("'max_hands' must be positive")
|
|
74
78
|
|
|
75
|
-
if
|
|
79
|
+
if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
|
|
80
|
+
or not (0.0 <= min_confidence <= 1.0):
|
|
76
81
|
raise ValueError("'min_confidence' must be between 0.0 and 1.0")
|
|
77
82
|
|
|
78
83
|
if landmarks_idx is not None and (
|
|
79
84
|
not isinstance(landmarks_idx, list)
|
|
80
|
-
or not all(isinstance(i, int) for i in landmarks_idx)
|
|
85
|
+
or not all(isinstance(i, int) and not isinstance(i, bool) for i in landmarks_idx)
|
|
81
86
|
):
|
|
82
87
|
raise TypeError("'landmarks_idx' must be a list of integers")
|
|
83
88
|
|
|
@@ -174,10 +179,11 @@ def detect_hands_live(
|
|
|
174
179
|
RuntimeError: If camera cannot be accessed
|
|
175
180
|
"""
|
|
176
181
|
# Validate parameters
|
|
177
|
-
if not isinstance(max_hands, int) or max_hands <= 0:
|
|
182
|
+
if not isinstance(max_hands, int) or isinstance(max_hands, bool) or max_hands <= 0:
|
|
178
183
|
raise ValueError("'max_hands' must be positive")
|
|
179
184
|
|
|
180
|
-
if
|
|
185
|
+
if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
|
|
186
|
+
or not (0.0 <= min_confidence <= 1.0):
|
|
181
187
|
raise ValueError("'min_confidence' must be between 0.0 and 1.0")
|
|
182
188
|
|
|
183
189
|
try:
|
|
@@ -188,8 +194,8 @@ def detect_hands_live(
|
|
|
188
194
|
'tracking. Install it with: pip install "ImagePRO-Python[mediapipe]"'
|
|
189
195
|
) from err
|
|
190
196
|
|
|
191
|
-
# Initialize camera
|
|
192
|
-
cap = cv2.VideoCapture(0,
|
|
197
|
+
# Initialize camera (backend-agnostic; see DEFAULT_CAMERA_BACKEND)
|
|
198
|
+
cap = cv2.VideoCapture(0, DEFAULT_CAMERA_BACKEND)
|
|
193
199
|
if not cap.isOpened():
|
|
194
200
|
raise RuntimeError("Cannot access webcam")
|
|
195
201
|
|