maque 0.2.1__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.
- maque/__init__.py +30 -0
- maque/__main__.py +926 -0
- maque/ai_platform/__init__.py +0 -0
- maque/ai_platform/crawl.py +45 -0
- maque/ai_platform/metrics.py +258 -0
- maque/ai_platform/nlp_preprocess.py +67 -0
- maque/ai_platform/webpage_screen_shot.py +195 -0
- maque/algorithms/__init__.py +78 -0
- maque/algorithms/bezier.py +15 -0
- maque/algorithms/bktree.py +117 -0
- maque/algorithms/core.py +104 -0
- maque/algorithms/hilbert.py +16 -0
- maque/algorithms/rate_function.py +92 -0
- maque/algorithms/transform.py +27 -0
- maque/algorithms/trie.py +272 -0
- maque/algorithms/utils.py +63 -0
- maque/algorithms/video.py +587 -0
- maque/api/__init__.py +1 -0
- maque/api/common.py +110 -0
- maque/api/fetch.py +26 -0
- maque/api/static/icon.png +0 -0
- maque/api/static/redoc.standalone.js +1782 -0
- maque/api/static/swagger-ui-bundle.js +3 -0
- maque/api/static/swagger-ui.css +3 -0
- maque/cli/__init__.py +1 -0
- maque/cli/clean_invisible_chars.py +324 -0
- maque/cli/core.py +34 -0
- maque/cli/groups/__init__.py +26 -0
- maque/cli/groups/config.py +205 -0
- maque/cli/groups/data.py +615 -0
- maque/cli/groups/doctor.py +259 -0
- maque/cli/groups/embedding.py +222 -0
- maque/cli/groups/git.py +29 -0
- maque/cli/groups/help.py +410 -0
- maque/cli/groups/llm.py +223 -0
- maque/cli/groups/mcp.py +241 -0
- maque/cli/groups/mllm.py +1795 -0
- maque/cli/groups/mllm_simple.py +60 -0
- maque/cli/groups/quant.py +210 -0
- maque/cli/groups/service.py +490 -0
- maque/cli/groups/system.py +570 -0
- maque/cli/mllm_run.py +1451 -0
- maque/cli/script.py +52 -0
- maque/cli/tree.py +49 -0
- maque/clustering/__init__.py +52 -0
- maque/clustering/analyzer.py +347 -0
- maque/clustering/clusterers.py +464 -0
- maque/clustering/sampler.py +134 -0
- maque/clustering/visualizer.py +205 -0
- maque/constant.py +13 -0
- maque/core.py +133 -0
- maque/cv/__init__.py +1 -0
- maque/cv/image.py +219 -0
- maque/cv/utils.py +68 -0
- maque/cv/video/__init__.py +3 -0
- maque/cv/video/keyframe_extractor.py +368 -0
- maque/embedding/__init__.py +43 -0
- maque/embedding/base.py +56 -0
- maque/embedding/multimodal.py +308 -0
- maque/embedding/server.py +523 -0
- maque/embedding/text.py +311 -0
- maque/git/__init__.py +24 -0
- maque/git/pure_git.py +912 -0
- maque/io/__init__.py +29 -0
- maque/io/core.py +38 -0
- maque/io/ops.py +194 -0
- maque/llm/__init__.py +111 -0
- maque/llm/backend.py +416 -0
- maque/llm/base.py +411 -0
- maque/llm/server.py +366 -0
- maque/mcp_server.py +1096 -0
- maque/mllm_data_processor_pipeline/__init__.py +17 -0
- maque/mllm_data_processor_pipeline/core.py +341 -0
- maque/mllm_data_processor_pipeline/example.py +291 -0
- maque/mllm_data_processor_pipeline/steps/__init__.py +56 -0
- maque/mllm_data_processor_pipeline/steps/data_alignment.py +267 -0
- maque/mllm_data_processor_pipeline/steps/data_loader.py +172 -0
- maque/mllm_data_processor_pipeline/steps/data_validation.py +304 -0
- maque/mllm_data_processor_pipeline/steps/format_conversion.py +411 -0
- maque/mllm_data_processor_pipeline/steps/mllm_annotation.py +331 -0
- maque/mllm_data_processor_pipeline/steps/mllm_refinement.py +446 -0
- maque/mllm_data_processor_pipeline/steps/result_validation.py +501 -0
- maque/mllm_data_processor_pipeline/web_app.py +317 -0
- maque/nlp/__init__.py +14 -0
- maque/nlp/ngram.py +9 -0
- maque/nlp/parser.py +63 -0
- maque/nlp/risk_matcher.py +543 -0
- maque/nlp/sentence_splitter.py +202 -0
- maque/nlp/simple_tradition_cvt.py +31 -0
- maque/performance/__init__.py +21 -0
- maque/performance/_measure_time.py +70 -0
- maque/performance/_profiler.py +367 -0
- maque/performance/_stat_memory.py +51 -0
- maque/pipelines/__init__.py +15 -0
- maque/pipelines/clustering.py +252 -0
- maque/quantization/__init__.py +42 -0
- maque/quantization/auto_round.py +120 -0
- maque/quantization/base.py +145 -0
- maque/quantization/bitsandbytes.py +127 -0
- maque/quantization/llm_compressor.py +102 -0
- maque/retriever/__init__.py +35 -0
- maque/retriever/chroma.py +654 -0
- maque/retriever/document.py +140 -0
- maque/retriever/milvus.py +1140 -0
- maque/table_ops/__init__.py +1 -0
- maque/table_ops/core.py +133 -0
- maque/table_viewer/__init__.py +4 -0
- maque/table_viewer/download_assets.py +57 -0
- maque/table_viewer/server.py +698 -0
- maque/table_viewer/static/element-plus-icons.js +5791 -0
- maque/table_viewer/static/element-plus.css +1 -0
- maque/table_viewer/static/element-plus.js +65236 -0
- maque/table_viewer/static/main.css +268 -0
- maque/table_viewer/static/main.js +669 -0
- maque/table_viewer/static/vue.global.js +18227 -0
- maque/table_viewer/templates/index.html +401 -0
- maque/utils/__init__.py +56 -0
- maque/utils/color.py +68 -0
- maque/utils/color_string.py +45 -0
- maque/utils/compress.py +66 -0
- maque/utils/constant.py +183 -0
- maque/utils/core.py +261 -0
- maque/utils/cursor.py +143 -0
- maque/utils/distance.py +58 -0
- maque/utils/docker.py +96 -0
- maque/utils/downloads.py +51 -0
- maque/utils/excel_helper.py +542 -0
- maque/utils/helper_metrics.py +121 -0
- maque/utils/helper_parser.py +168 -0
- maque/utils/net.py +64 -0
- maque/utils/nvidia_stat.py +140 -0
- maque/utils/ops.py +53 -0
- maque/utils/packages.py +31 -0
- maque/utils/path.py +57 -0
- maque/utils/tar.py +260 -0
- maque/utils/untar.py +129 -0
- maque/web/__init__.py +0 -0
- maque/web/image_downloader.py +1410 -0
- maque-0.2.1.dist-info/METADATA +450 -0
- maque-0.2.1.dist-info/RECORD +143 -0
- maque-0.2.1.dist-info/WHEEL +4 -0
- maque-0.2.1.dist-info/entry_points.txt +3 -0
- maque-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""video_frame_deduplicator.py – lightweight, **multiprocess‑capable** duplicate‑frame detector
|
|
3
|
+
|
|
4
|
+
* Works for **video files** *and* in‑memory **image lists**.
|
|
5
|
+
* Pure‑Python / NumPy / OpenCV; Pillow optional (for PIL.Image input).
|
|
6
|
+
* Optional **multi‑processing** (set *workers>1*) accelerates perceptual‑hash
|
|
7
|
+
computation on multi‑core CPUs.
|
|
8
|
+
|
|
9
|
+
CLI examples
|
|
10
|
+
------------
|
|
11
|
+
» Extract unique frames (single‑process):
|
|
12
|
+
```bash
|
|
13
|
+
python video_frame_deduplicator.py input.mp4
|
|
14
|
+
```
|
|
15
|
+
» Same but with 8 worker processes:
|
|
16
|
+
```bash
|
|
17
|
+
python video_frame_deduplicator.py input.mp4 phash 8
|
|
18
|
+
```
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
|
25
|
+
|
|
26
|
+
import cv2
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from PIL import Image # type: ignore
|
|
31
|
+
|
|
32
|
+
_HAS_PIL = True
|
|
33
|
+
except ImportError: # Pillow is optional
|
|
34
|
+
_HAS_PIL = False
|
|
35
|
+
|
|
36
|
+
################################################################################
|
|
37
|
+
# NumPy fallbacks for pHash / dHash / aHash + Hamming distance helper
|
|
38
|
+
################################################################################
|
|
39
|
+
|
|
40
|
+
def _phash_numpy(img: np.ndarray, hash_size: int = 8) -> int:
|
|
41
|
+
gray = cv2.resize(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), (32, 32), interpolation=cv2.INTER_AREA)
|
|
42
|
+
dct = cv2.dct(np.float32(gray))
|
|
43
|
+
low = dct[:hash_size, :hash_size]
|
|
44
|
+
# med = np.median(low)
|
|
45
|
+
med = np.mean(low)
|
|
46
|
+
bits = (low > med).flatten()
|
|
47
|
+
return int("".join("1" if b else "0" for b in bits), 2)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ahash_numpy(img: np.ndarray, hash_size: int = 8) -> int:
|
|
51
|
+
gray = cv2.resize(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), (hash_size, hash_size), interpolation=cv2.INTER_AREA)
|
|
52
|
+
med = gray.mean()
|
|
53
|
+
bits = (gray > med).flatten()
|
|
54
|
+
return int("".join("1" if b else "0" for b in bits), 2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _dhash_numpy(img: np.ndarray, hash_size: int = 8) -> int:
|
|
58
|
+
gray = cv2.resize(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA)
|
|
59
|
+
diff = gray[:, 1:] > gray[:, :-1]
|
|
60
|
+
return int("".join("1" if b else "0" for b in diff.flatten()), 2)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hamming(a: int, b: int) -> int:
|
|
64
|
+
return (a ^ b).bit_count()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
################################################################################
|
|
68
|
+
# Multiprocessing worker (must be top‑level so it can be pickled)
|
|
69
|
+
################################################################################
|
|
70
|
+
|
|
71
|
+
def _sig_worker(args):
|
|
72
|
+
"""Compute signature in a subprocess. Arguments are packed to avoid global state."""
|
|
73
|
+
idx, img_bgr, method, resize = args
|
|
74
|
+
|
|
75
|
+
# resize (kept identical to main‑process logic)
|
|
76
|
+
if resize:
|
|
77
|
+
h, w = img_bgr.shape[:2]
|
|
78
|
+
scale = resize / w
|
|
79
|
+
img_bgr = cv2.resize(img_bgr, (resize, int(h * scale)), interpolation=cv2.INTER_AREA)
|
|
80
|
+
|
|
81
|
+
if method == "phash":
|
|
82
|
+
sig = _phash_numpy(img_bgr)
|
|
83
|
+
elif method == "dhash":
|
|
84
|
+
sig = _dhash_numpy(img_bgr)
|
|
85
|
+
elif method == "ahash":
|
|
86
|
+
sig = _ahash_numpy(img_bgr)
|
|
87
|
+
else:
|
|
88
|
+
raise RuntimeError("_sig_worker only supports hash methods (phash/dhash/ahash)")
|
|
89
|
+
# return idx to restore original order
|
|
90
|
+
return idx, sig
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
################################################################################
|
|
94
|
+
# Main class
|
|
95
|
+
################################################################################
|
|
96
|
+
class VideoFrameDeduplicator:
|
|
97
|
+
"""Duplicate detector with optional multi‑processing.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
method : {"phash", "dhash", "ahash", "hist", "mse", "ssim"}
|
|
102
|
+
threshold : float | None. If None, uses method‑specific default.
|
|
103
|
+
step : int ≥1. Sample every *step* frames/images.
|
|
104
|
+
resize : int. Pre‑resize width (keep aspect). 0 disables.
|
|
105
|
+
workers : int ≥1. 1 → single‑process (default). >1 → mp.
|
|
106
|
+
*Currently mp acceleration applies to hash methods only.*
|
|
107
|
+
fps : float | None. Frames per second. 0 disables.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_DEFAULTS = {
|
|
111
|
+
"phash": 5,
|
|
112
|
+
"dhash": 5,
|
|
113
|
+
"ahash": 5,
|
|
114
|
+
"hist": 0.95, # correlation ≥ 0.95 → duplicate
|
|
115
|
+
"mse": 4.0, # mse ≤ 4 → duplicate
|
|
116
|
+
"ssim": 0.98, # ssim ≥ 0.98 → duplicate
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
###########################################################################
|
|
120
|
+
# Init & helpers
|
|
121
|
+
###########################################################################
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
method: str = "phash",
|
|
126
|
+
threshold: Optional[float] = None,
|
|
127
|
+
step: int = 1,
|
|
128
|
+
resize: int = 256,
|
|
129
|
+
workers: int = 1,
|
|
130
|
+
fps: Optional[float] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self.method = method.lower()
|
|
133
|
+
if self.method not in self._DEFAULTS:
|
|
134
|
+
raise ValueError(f"Unsupported method '{self.method}'.")
|
|
135
|
+
|
|
136
|
+
# Validate step and fps: only one can be set (unless step is default 1)
|
|
137
|
+
if step != 1 and fps is not None:
|
|
138
|
+
raise ValueError("Cannot specify both 'step' (other than 1) and 'fps'.")
|
|
139
|
+
|
|
140
|
+
self.thr = threshold if threshold is not None else self._DEFAULTS[self.method]
|
|
141
|
+
self.step = max(1, step)
|
|
142
|
+
self.resize = resize
|
|
143
|
+
self.workers = max(1, workers)
|
|
144
|
+
self.fps = fps
|
|
145
|
+
|
|
146
|
+
# Prefer OpenCV's img_hash where available (single‑process only!)
|
|
147
|
+
self._use_cv_hash = False
|
|
148
|
+
if self.method in ("phash", "dhash", "ahash") and self.workers == 1:
|
|
149
|
+
try:
|
|
150
|
+
alg_map = {
|
|
151
|
+
"phash": cv2.img_hash.PHash_create,
|
|
152
|
+
"dhash": cv2.img_hash.BlockMeanHash_create, # mode=1 (dHash‑like)
|
|
153
|
+
"ahash": cv2.img_hash.AverageHash_create,
|
|
154
|
+
}
|
|
155
|
+
self._hash_alg = alg_map[self.method]()
|
|
156
|
+
self._use_cv_hash = True
|
|
157
|
+
except Exception:
|
|
158
|
+
pass # contrib not available → fall back to NumPy routines
|
|
159
|
+
|
|
160
|
+
self._prev_sig = None # reset by each public API
|
|
161
|
+
|
|
162
|
+
# ------------------------------ utils -----------------------------------
|
|
163
|
+
def _to_bgr(self, img: Union[np.ndarray, "Image.Image"]) -> np.ndarray:
|
|
164
|
+
if _HAS_PIL and isinstance(img, Image.Image):
|
|
165
|
+
img = np.asarray(img.convert("RGB"))[:, :, ::-1] # PIL RGB → BGR
|
|
166
|
+
elif not isinstance(img, np.ndarray):
|
|
167
|
+
raise TypeError("Input must be np.ndarray or PIL.Image")
|
|
168
|
+
if img.ndim == 2:
|
|
169
|
+
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
|
170
|
+
elif img.shape[2] == 4:
|
|
171
|
+
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
|
|
172
|
+
return img
|
|
173
|
+
|
|
174
|
+
# --------------------------- signature & distance ------------------------
|
|
175
|
+
def _signature(self, frame: np.ndarray):
|
|
176
|
+
if self.method in ("phash", "dhash", "ahash"):
|
|
177
|
+
if self._use_cv_hash:
|
|
178
|
+
return self._hash_alg.compute(frame)
|
|
179
|
+
# workers==1 but contrib missing ➜ NumPy fallback
|
|
180
|
+
if self.method == "phash":
|
|
181
|
+
return _phash_numpy(frame)
|
|
182
|
+
if self.method == "dhash":
|
|
183
|
+
return _dhash_numpy(frame)
|
|
184
|
+
return _ahash_numpy(frame)
|
|
185
|
+
elif self.method == "hist":
|
|
186
|
+
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
|
|
187
|
+
hist = cv2.calcHist([hsv], [0, 1], None, [50, 60], [0, 180, 0, 256])
|
|
188
|
+
return cv2.normalize(hist, hist).flatten()
|
|
189
|
+
elif self.method in ("mse", "ssim"):
|
|
190
|
+
return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
191
|
+
else:
|
|
192
|
+
raise RuntimeError
|
|
193
|
+
|
|
194
|
+
def _distance(self, sig1, sig2) -> float:
|
|
195
|
+
if self.method in ("phash", "dhash", "ahash"):
|
|
196
|
+
if self._use_cv_hash:
|
|
197
|
+
return cv2.norm(sig1, sig2, cv2.NORM_HAMMING)
|
|
198
|
+
return _hamming(sig1, sig2)
|
|
199
|
+
elif self.method == "hist":
|
|
200
|
+
return cv2.compareHist(sig1.astype(np.float32), sig2.astype(np.float32), cv2.HISTCMP_CORREL)
|
|
201
|
+
elif self.method == "mse":
|
|
202
|
+
diff = sig1.astype(np.int16) - sig2.astype(np.int16)
|
|
203
|
+
return float(np.mean(diff * diff))
|
|
204
|
+
elif self.method == "ssim":
|
|
205
|
+
C1, C2 = 6.5025, 58.5225
|
|
206
|
+
mu1 = cv2.GaussianBlur(sig1, (11, 11), 1.5)
|
|
207
|
+
mu2 = cv2.GaussianBlur(sig2, (11, 11), 1.5)
|
|
208
|
+
mu1_sq, mu2_sq, mu1_mu2 = mu1 ** 2, mu2 ** 2, mu1 * mu2
|
|
209
|
+
sigma1_sq = cv2.GaussianBlur(sig1 ** 2, (11, 11), 1.5) - mu1_sq
|
|
210
|
+
sigma2_sq = cv2.GaussianBlur(sig2 ** 2, (11, 11), 1.5) - mu2_sq
|
|
211
|
+
sigma12 = cv2.GaussianBlur(sig1 * sig2, (11, 11), 1.5) - mu1_mu2
|
|
212
|
+
ssim_map = ((2 * mu1_mu2 + C1) * (2 * sigma12 + C2)) / (
|
|
213
|
+
(mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2)
|
|
214
|
+
)
|
|
215
|
+
return float(ssim_map.mean())
|
|
216
|
+
else:
|
|
217
|
+
raise RuntimeError
|
|
218
|
+
|
|
219
|
+
def _is_duplicate(self, dist: float) -> bool:
|
|
220
|
+
# hist & ssim: higher similarity → duplicate
|
|
221
|
+
if self.method in ("hist", "ssim"):
|
|
222
|
+
return dist >= self.thr
|
|
223
|
+
return dist <= self.thr # hashes and mse: lower distance → duplicate
|
|
224
|
+
|
|
225
|
+
# --------------------------- keep‑decision ------------------------------
|
|
226
|
+
def _keep(self, sig) -> bool:
|
|
227
|
+
if self._prev_sig is None:
|
|
228
|
+
self._prev_sig = sig
|
|
229
|
+
return True
|
|
230
|
+
dist = self._distance(sig, self._prev_sig)
|
|
231
|
+
if not self._is_duplicate(dist):
|
|
232
|
+
self._prev_sig = sig
|
|
233
|
+
return True
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
###########################################################################
|
|
237
|
+
# Public API: video ------------------------------------------------------
|
|
238
|
+
###########################################################################
|
|
239
|
+
def unique_frames(self, src: Union[str, Path, int]) -> Iterable[Tuple[int, np.ndarray]]:
|
|
240
|
+
"""Yield unique video frames (index, BGR ndarray). Uses mp if workers>1 & hash methods."""
|
|
241
|
+
cap = cv2.VideoCapture(str(src))
|
|
242
|
+
if not cap.isOpened():
|
|
243
|
+
raise FileNotFoundError(f"Cannot open video {src}")
|
|
244
|
+
|
|
245
|
+
# Determine effective step based on fps or step
|
|
246
|
+
video_fps = cap.get(cv2.CAP_PROP_FPS)
|
|
247
|
+
effective_step = self.step
|
|
248
|
+
if self.fps is not None:
|
|
249
|
+
if video_fps and video_fps > 0:
|
|
250
|
+
effective_step = max(1, round(video_fps / self.fps))
|
|
251
|
+
else:
|
|
252
|
+
# Warn if FPS is unavailable and fps parameter is set
|
|
253
|
+
import sys
|
|
254
|
+
print(f"Warning: Could not determine FPS for video '{src}'. Ignoring 'fps' parameter.", file=sys.stderr)
|
|
255
|
+
effective_step = 1 # Default to processing every frame if FPS unknown
|
|
256
|
+
elif self.step == 1:
|
|
257
|
+
# Default case: if neither fps nor step>1 is specified, process every frame
|
|
258
|
+
effective_step = 1
|
|
259
|
+
|
|
260
|
+
self._prev_sig = None
|
|
261
|
+
idx = -1
|
|
262
|
+
|
|
263
|
+
if self.workers == 1 or self.method not in ("phash", "dhash", "ahash"):
|
|
264
|
+
# ---------------- single‑process path ----------------
|
|
265
|
+
while True:
|
|
266
|
+
ok, frame = cap.read()
|
|
267
|
+
if not ok:
|
|
268
|
+
break
|
|
269
|
+
idx += 1
|
|
270
|
+
if idx % effective_step: # Use effective_step
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# resize beforehand (single‑process path)
|
|
274
|
+
if self.resize:
|
|
275
|
+
h, w = frame.shape[:2]
|
|
276
|
+
scale = self.resize / w
|
|
277
|
+
frame_proc = cv2.resize(frame, (self.resize, int(h * scale)), interpolation=cv2.INTER_AREA)
|
|
278
|
+
else:
|
|
279
|
+
frame_proc = frame
|
|
280
|
+
|
|
281
|
+
sig = self._signature(frame_proc)
|
|
282
|
+
if self._keep(sig):
|
|
283
|
+
yield idx, frame
|
|
284
|
+
else:
|
|
285
|
+
# ---------------- multi‑process path -----------------
|
|
286
|
+
# collect candidate frames first (step filtering) to minimise IPC
|
|
287
|
+
frames: List[Tuple[int, np.ndarray]] = []
|
|
288
|
+
while True:
|
|
289
|
+
ok, frame = cap.read()
|
|
290
|
+
if not ok:
|
|
291
|
+
break
|
|
292
|
+
idx += 1
|
|
293
|
+
if idx % effective_step == 0: # Use effective_step
|
|
294
|
+
frames.append((idx, frame))
|
|
295
|
+
cap.release()
|
|
296
|
+
|
|
297
|
+
# compute signatures in parallel using fallbacks (cv2.img_hash not picklable)
|
|
298
|
+
with ProcessPoolExecutor(max_workers=self.workers) as pool:
|
|
299
|
+
sigs = list(
|
|
300
|
+
pool.map(
|
|
301
|
+
_sig_worker,
|
|
302
|
+
[
|
|
303
|
+
(i, f, self.method, self.resize)
|
|
304
|
+
for i, f in frames
|
|
305
|
+
],
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# merge idx→sig dict for ordered pass
|
|
310
|
+
sig_dict = {i: s for i, s in sigs}
|
|
311
|
+
for i, frame in frames:
|
|
312
|
+
if self._keep(sig_dict[i]):
|
|
313
|
+
yield i, frame
|
|
314
|
+
return # explicit
|
|
315
|
+
|
|
316
|
+
###########################################################################
|
|
317
|
+
# Public API: images -----------------------------------------------------
|
|
318
|
+
###########################################################################
|
|
319
|
+
def iter_unique_images(
|
|
320
|
+
self, images: Sequence[Union[np.ndarray, "Image.Image"]]
|
|
321
|
+
) -> Iterable[Tuple[int, np.ndarray]]:
|
|
322
|
+
"""Generator. mp acceleration when workers>1 & hash methods."""
|
|
323
|
+
# Check if fps is set, which is invalid for image sequences
|
|
324
|
+
if self.fps is not None:
|
|
325
|
+
raise ValueError("'fps' parameter is not applicable to image sequences.")
|
|
326
|
+
|
|
327
|
+
self._prev_sig = None
|
|
328
|
+
|
|
329
|
+
# ------------ single‑process or non‑hash path -------------
|
|
330
|
+
if self.workers == 1 or self.method not in ("phash", "dhash", "ahash"):
|
|
331
|
+
for idx, img in enumerate(images):
|
|
332
|
+
if idx % self.step:
|
|
333
|
+
continue
|
|
334
|
+
bgr = self._to_bgr(img)
|
|
335
|
+
|
|
336
|
+
# resize
|
|
337
|
+
if self.resize:
|
|
338
|
+
h, w = bgr.shape[:2]
|
|
339
|
+
scale = self.resize / w
|
|
340
|
+
bgr_proc = cv2.resize(bgr, (self.resize, int(h * scale)), interpolation=cv2.INTER_AREA)
|
|
341
|
+
else:
|
|
342
|
+
bgr_proc = bgr
|
|
343
|
+
|
|
344
|
+
sig = self._signature(bgr_proc)
|
|
345
|
+
if self._keep(sig):
|
|
346
|
+
yield idx, bgr
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
# ------------ multi‑process hash path -------------
|
|
350
|
+
# Pre‑convert images to BGR ndarrays in main process (more stable than PIL inside pool)
|
|
351
|
+
candidates: List[Tuple[int, np.ndarray]] = []
|
|
352
|
+
for idx, img in enumerate(images):
|
|
353
|
+
if idx % self.step == 0:
|
|
354
|
+
candidates.append((idx, self._to_bgr(img)))
|
|
355
|
+
|
|
356
|
+
with ProcessPoolExecutor(max_workers=self.workers) as pool:
|
|
357
|
+
sigs = list(
|
|
358
|
+
pool.map(
|
|
359
|
+
_sig_worker,
|
|
360
|
+
[
|
|
361
|
+
(idx, img, self.method, self.resize) for idx, img in candidates
|
|
362
|
+
],
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
sig_dict = {i: s for i, s in sigs}
|
|
366
|
+
|
|
367
|
+
for idx, bgr in candidates:
|
|
368
|
+
if self._keep(sig_dict[idx]):
|
|
369
|
+
yield idx, bgr
|
|
370
|
+
|
|
371
|
+
def unique_images(
|
|
372
|
+
self,
|
|
373
|
+
images: Sequence[Union[np.ndarray, "Image.Image"]],
|
|
374
|
+
return_indices: bool = False,
|
|
375
|
+
) -> List[Union[np.ndarray, Tuple[int, np.ndarray]]]:
|
|
376
|
+
uniques: List[Union[np.ndarray, Tuple[int, np.ndarray]]] = []
|
|
377
|
+
for idx, img in self.iter_unique_images(images):
|
|
378
|
+
uniques.append((idx, img) if return_indices else img)
|
|
379
|
+
return uniques
|
|
380
|
+
|
|
381
|
+
###########################################################################
|
|
382
|
+
# Public API: Process and Save ------------------------------------------
|
|
383
|
+
###########################################################################
|
|
384
|
+
def process_and_save_unique_frames(self, src: Union[str, Path, int], out_dir: Union[str, Path]) -> int:
|
|
385
|
+
"""Process video/images, find unique frames, and save them to out_dir.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
src (str | Path | int): Path to video file, device index, or sequence of images.
|
|
389
|
+
out_dir (str | Path): Directory to save unique frames.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
int: Number of unique frames saved.
|
|
393
|
+
"""
|
|
394
|
+
# Create base output directory
|
|
395
|
+
output_path = Path(out_dir)
|
|
396
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
|
|
398
|
+
# Extract filename from source (if src is a path)
|
|
399
|
+
if isinstance(src, (str, Path)) and not isinstance(src, int):
|
|
400
|
+
src_path = Path(src)
|
|
401
|
+
if src_path.exists() and src_path.is_file():
|
|
402
|
+
# Use filename without extension as subdirectory
|
|
403
|
+
filename_no_ext = src_path.stem
|
|
404
|
+
# Create subdirectory with input filename
|
|
405
|
+
output_path = output_path / filename_no_ext
|
|
406
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
|
|
408
|
+
print(f"Processing '{src}' with method='{self.method}', threshold={self.thr}, "
|
|
409
|
+
f"step={self.step if self.fps is None else 'N/A'}, fps={self.fps if self.fps is not None else 'N/A'}, "
|
|
410
|
+
f"resize={self.resize}, workers={self.workers}...")
|
|
411
|
+
print(f"Saving unique frames to '{output_path}'...")
|
|
412
|
+
|
|
413
|
+
count = 0
|
|
414
|
+
try:
|
|
415
|
+
# Check if src is an image sequence or video path/index
|
|
416
|
+
if isinstance(src, (list, tuple)):
|
|
417
|
+
# Assuming src is a sequence of images (np.ndarray or PIL.Image)
|
|
418
|
+
# Note: This part currently assumes unique_frames handles image sequences correctly
|
|
419
|
+
# If only video sources are intended for this method, add a check here.
|
|
420
|
+
iterator = self.iter_unique_images(src)
|
|
421
|
+
else:
|
|
422
|
+
# Assuming src is a video file path or device index
|
|
423
|
+
iterator = self.unique_frames(src)
|
|
424
|
+
|
|
425
|
+
for idx, frame in iterator:
|
|
426
|
+
frame_filename = output_path / f"{idx:06d}.jpg"
|
|
427
|
+
cv2.imwrite(str(frame_filename), frame)
|
|
428
|
+
count += 1
|
|
429
|
+
except FileNotFoundError as e:
|
|
430
|
+
print(f"Error: Input source '{src}' not found or could not be opened. {e}")
|
|
431
|
+
# Re-raise or handle as needed
|
|
432
|
+
raise
|
|
433
|
+
except Exception as e:
|
|
434
|
+
print(f"An error occurred during processing or saving: {e}")
|
|
435
|
+
# Re-raise or handle as needed
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
print(f"Successfully saved {count} unique frames to ./{output_path}/")
|
|
439
|
+
return count
|
|
440
|
+
|
|
441
|
+
def frames_to_video(self, frames_dir: Union[str, Path], output_video: Union[str, Path] = None, fps: float = 15.0, codec: str = 'mp4v', use_av: bool = False) -> str:
|
|
442
|
+
"""
|
|
443
|
+
将一个目录中的帧图像合成为视频。
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
frames_dir (str | Path): 包含帧图像的目录路径。
|
|
447
|
+
output_video (str | Path, optional): 输出视频的路径。如果为None,则默认为frames_dir旁边的同名mp4文件。
|
|
448
|
+
fps (float, optional): 输出视频的帧率。默认为15.0。
|
|
449
|
+
codec (str, optional): 视频编解码器。默认为'mp4v',可选'avc1'等。
|
|
450
|
+
use_av (bool, optional): 是否使用PyAV库加速(如果可用)。默认为False。
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
str: 输出视频的路径。
|
|
454
|
+
"""
|
|
455
|
+
frames_dir = Path(frames_dir)
|
|
456
|
+
if not frames_dir.exists() or not frames_dir.is_dir():
|
|
457
|
+
raise ValueError(f"帧目录'{frames_dir}'不存在或不是一个目录。")
|
|
458
|
+
|
|
459
|
+
# 确定输出视频路径
|
|
460
|
+
if output_video is None:
|
|
461
|
+
output_video = str(frames_dir.parent / f"{frames_dir.name}.mp4")
|
|
462
|
+
else:
|
|
463
|
+
output_video = str(output_video)
|
|
464
|
+
|
|
465
|
+
# 获取帧图像文件列表并排序
|
|
466
|
+
frame_files = sorted([f for f in frames_dir.glob("*.jpg") or frames_dir.glob("*.png") or frames_dir.glob("*.jpeg")])
|
|
467
|
+
if not frame_files:
|
|
468
|
+
raise ValueError(f"在目录'{frames_dir}'中未找到图像文件。")
|
|
469
|
+
|
|
470
|
+
# 读取第一帧以获取宽度和高度
|
|
471
|
+
first_frame = cv2.imread(str(frame_files[0]))
|
|
472
|
+
if first_frame is None:
|
|
473
|
+
raise ValueError(f"无法读取图像文件'{frame_files[0]}'。")
|
|
474
|
+
|
|
475
|
+
height, width = first_frame.shape[:2]
|
|
476
|
+
|
|
477
|
+
# 尝试使用PyAV加速(如果请求并可用)
|
|
478
|
+
if use_av:
|
|
479
|
+
try:
|
|
480
|
+
import av
|
|
481
|
+
import numpy as np
|
|
482
|
+
print(f"使用PyAV库合成视频...")
|
|
483
|
+
|
|
484
|
+
# 将OpenCV编解码器转换为PyAV编解码器
|
|
485
|
+
av_codec = codec
|
|
486
|
+
if codec == 'mp4v':
|
|
487
|
+
av_codec = 'libx264' # 对于mp4v,使用libx264
|
|
488
|
+
elif codec == 'avc1':
|
|
489
|
+
av_codec = 'libx264' # avc1同样使用libx264
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
container = av.open(output_video, mode='w')
|
|
493
|
+
stream = container.add_stream(av_codec, rate=fps)
|
|
494
|
+
stream.width = width
|
|
495
|
+
stream.height = height
|
|
496
|
+
stream.pix_fmt = 'yuv420p'
|
|
497
|
+
|
|
498
|
+
for frame_file in frame_files:
|
|
499
|
+
img = cv2.imread(str(frame_file))
|
|
500
|
+
# OpenCV使用BGR,转换为RGB
|
|
501
|
+
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
502
|
+
frame = av.VideoFrame.from_ndarray(img_rgb, format='rgb24')
|
|
503
|
+
packet = stream.encode(frame)
|
|
504
|
+
container.mux(packet)
|
|
505
|
+
|
|
506
|
+
# 刷新缓冲区
|
|
507
|
+
packet = stream.encode(None)
|
|
508
|
+
container.mux(packet)
|
|
509
|
+
container.close()
|
|
510
|
+
|
|
511
|
+
print(f"视频合成完成,输出到: {output_video}")
|
|
512
|
+
return output_video
|
|
513
|
+
except Exception as e:
|
|
514
|
+
print(f"PyAV编解码器错误: {e}")
|
|
515
|
+
print("尝试使用默认编解码器...")
|
|
516
|
+
|
|
517
|
+
# 如果特定编解码器失败,尝试使用默认编解码器
|
|
518
|
+
container = av.open(output_video, mode='w')
|
|
519
|
+
stream = container.add_stream('libx264', rate=fps)
|
|
520
|
+
stream.width = width
|
|
521
|
+
stream.height = height
|
|
522
|
+
stream.pix_fmt = 'yuv420p'
|
|
523
|
+
|
|
524
|
+
for frame_file in frame_files:
|
|
525
|
+
img = cv2.imread(str(frame_file))
|
|
526
|
+
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
527
|
+
frame = av.VideoFrame.from_ndarray(img_rgb, format='rgb24')
|
|
528
|
+
packet = stream.encode(frame)
|
|
529
|
+
container.mux(packet)
|
|
530
|
+
|
|
531
|
+
packet = stream.encode(None)
|
|
532
|
+
container.mux(packet)
|
|
533
|
+
container.close()
|
|
534
|
+
|
|
535
|
+
print(f"视频合成完成,输出到: {output_video}")
|
|
536
|
+
return output_video
|
|
537
|
+
|
|
538
|
+
except (ImportError, Exception) as e:
|
|
539
|
+
print(f"PyAV库不可用或发生错误: {e}")
|
|
540
|
+
print("回退到OpenCV进行视频合成...")
|
|
541
|
+
|
|
542
|
+
# 使用OpenCV合成视频(默认)
|
|
543
|
+
print(f"使用OpenCV合成视频...")
|
|
544
|
+
fourcc = cv2.VideoWriter_fourcc(*codec)
|
|
545
|
+
out = cv2.VideoWriter(output_video, fourcc, fps, (width, height))
|
|
546
|
+
|
|
547
|
+
if not out.isOpened():
|
|
548
|
+
print(f"警告: 无法使用编解码器'{codec}'创建视频,尝试使用默认编解码器...")
|
|
549
|
+
# 尝试使用默认编解码器
|
|
550
|
+
default_codec = 'mp4v'
|
|
551
|
+
fourcc = cv2.VideoWriter_fourcc(*default_codec)
|
|
552
|
+
out = cv2.VideoWriter(output_video, fourcc, fps, (width, height))
|
|
553
|
+
|
|
554
|
+
if not out.isOpened():
|
|
555
|
+
raise RuntimeError(f"无法创建视频写入器,请检查编解码器是否支持。")
|
|
556
|
+
|
|
557
|
+
for frame_file in frame_files:
|
|
558
|
+
img = cv2.imread(str(frame_file))
|
|
559
|
+
if img is not None:
|
|
560
|
+
out.write(img)
|
|
561
|
+
else:
|
|
562
|
+
print(f"警告: 无法读取图像文件'{frame_file}',跳过。")
|
|
563
|
+
|
|
564
|
+
out.release()
|
|
565
|
+
print(f"视频合成完成,输出到: {output_video}")
|
|
566
|
+
return output_video
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
################################################################################
|
|
570
|
+
# Simple CLI helper
|
|
571
|
+
################################################################################
|
|
572
|
+
if __name__ == "__main__":
|
|
573
|
+
import sys
|
|
574
|
+
from maque.performance._measure_time import MeasureTime
|
|
575
|
+
|
|
576
|
+
if len(sys.argv) < 2:
|
|
577
|
+
print("请使用 maque CLI 来使用此功能。例如:")
|
|
578
|
+
print("python -m maque video_frame_dedup <video_file> [--fps 1] [--method phash] [--workers 4]")
|
|
579
|
+
print("python -m maque frames_to_video <frames_dir> [--fps 30] [--use-av]")
|
|
580
|
+
print("python -m maque dedup_and_create_video <video_file> [--fps 1] [--video-fps 30]")
|
|
581
|
+
sys.exit(0)
|
|
582
|
+
|
|
583
|
+
print("请使用 maque CLI 来使用此功能:")
|
|
584
|
+
print("1. 提取帧: python -m maque video_frame_dedup <video_file>")
|
|
585
|
+
print("2. 合成视频: python -m maque frames_to_video <frames_dir>")
|
|
586
|
+
print("3. 一体化流程: python -m maque dedup_and_create_video <video_file>")
|
|
587
|
+
sys.exit(0)
|
maque/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .common import create_app, ORJSONResponse
|