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.
Files changed (143) hide show
  1. maque/__init__.py +30 -0
  2. maque/__main__.py +926 -0
  3. maque/ai_platform/__init__.py +0 -0
  4. maque/ai_platform/crawl.py +45 -0
  5. maque/ai_platform/metrics.py +258 -0
  6. maque/ai_platform/nlp_preprocess.py +67 -0
  7. maque/ai_platform/webpage_screen_shot.py +195 -0
  8. maque/algorithms/__init__.py +78 -0
  9. maque/algorithms/bezier.py +15 -0
  10. maque/algorithms/bktree.py +117 -0
  11. maque/algorithms/core.py +104 -0
  12. maque/algorithms/hilbert.py +16 -0
  13. maque/algorithms/rate_function.py +92 -0
  14. maque/algorithms/transform.py +27 -0
  15. maque/algorithms/trie.py +272 -0
  16. maque/algorithms/utils.py +63 -0
  17. maque/algorithms/video.py +587 -0
  18. maque/api/__init__.py +1 -0
  19. maque/api/common.py +110 -0
  20. maque/api/fetch.py +26 -0
  21. maque/api/static/icon.png +0 -0
  22. maque/api/static/redoc.standalone.js +1782 -0
  23. maque/api/static/swagger-ui-bundle.js +3 -0
  24. maque/api/static/swagger-ui.css +3 -0
  25. maque/cli/__init__.py +1 -0
  26. maque/cli/clean_invisible_chars.py +324 -0
  27. maque/cli/core.py +34 -0
  28. maque/cli/groups/__init__.py +26 -0
  29. maque/cli/groups/config.py +205 -0
  30. maque/cli/groups/data.py +615 -0
  31. maque/cli/groups/doctor.py +259 -0
  32. maque/cli/groups/embedding.py +222 -0
  33. maque/cli/groups/git.py +29 -0
  34. maque/cli/groups/help.py +410 -0
  35. maque/cli/groups/llm.py +223 -0
  36. maque/cli/groups/mcp.py +241 -0
  37. maque/cli/groups/mllm.py +1795 -0
  38. maque/cli/groups/mllm_simple.py +60 -0
  39. maque/cli/groups/quant.py +210 -0
  40. maque/cli/groups/service.py +490 -0
  41. maque/cli/groups/system.py +570 -0
  42. maque/cli/mllm_run.py +1451 -0
  43. maque/cli/script.py +52 -0
  44. maque/cli/tree.py +49 -0
  45. maque/clustering/__init__.py +52 -0
  46. maque/clustering/analyzer.py +347 -0
  47. maque/clustering/clusterers.py +464 -0
  48. maque/clustering/sampler.py +134 -0
  49. maque/clustering/visualizer.py +205 -0
  50. maque/constant.py +13 -0
  51. maque/core.py +133 -0
  52. maque/cv/__init__.py +1 -0
  53. maque/cv/image.py +219 -0
  54. maque/cv/utils.py +68 -0
  55. maque/cv/video/__init__.py +3 -0
  56. maque/cv/video/keyframe_extractor.py +368 -0
  57. maque/embedding/__init__.py +43 -0
  58. maque/embedding/base.py +56 -0
  59. maque/embedding/multimodal.py +308 -0
  60. maque/embedding/server.py +523 -0
  61. maque/embedding/text.py +311 -0
  62. maque/git/__init__.py +24 -0
  63. maque/git/pure_git.py +912 -0
  64. maque/io/__init__.py +29 -0
  65. maque/io/core.py +38 -0
  66. maque/io/ops.py +194 -0
  67. maque/llm/__init__.py +111 -0
  68. maque/llm/backend.py +416 -0
  69. maque/llm/base.py +411 -0
  70. maque/llm/server.py +366 -0
  71. maque/mcp_server.py +1096 -0
  72. maque/mllm_data_processor_pipeline/__init__.py +17 -0
  73. maque/mllm_data_processor_pipeline/core.py +341 -0
  74. maque/mllm_data_processor_pipeline/example.py +291 -0
  75. maque/mllm_data_processor_pipeline/steps/__init__.py +56 -0
  76. maque/mllm_data_processor_pipeline/steps/data_alignment.py +267 -0
  77. maque/mllm_data_processor_pipeline/steps/data_loader.py +172 -0
  78. maque/mllm_data_processor_pipeline/steps/data_validation.py +304 -0
  79. maque/mllm_data_processor_pipeline/steps/format_conversion.py +411 -0
  80. maque/mllm_data_processor_pipeline/steps/mllm_annotation.py +331 -0
  81. maque/mllm_data_processor_pipeline/steps/mllm_refinement.py +446 -0
  82. maque/mllm_data_processor_pipeline/steps/result_validation.py +501 -0
  83. maque/mllm_data_processor_pipeline/web_app.py +317 -0
  84. maque/nlp/__init__.py +14 -0
  85. maque/nlp/ngram.py +9 -0
  86. maque/nlp/parser.py +63 -0
  87. maque/nlp/risk_matcher.py +543 -0
  88. maque/nlp/sentence_splitter.py +202 -0
  89. maque/nlp/simple_tradition_cvt.py +31 -0
  90. maque/performance/__init__.py +21 -0
  91. maque/performance/_measure_time.py +70 -0
  92. maque/performance/_profiler.py +367 -0
  93. maque/performance/_stat_memory.py +51 -0
  94. maque/pipelines/__init__.py +15 -0
  95. maque/pipelines/clustering.py +252 -0
  96. maque/quantization/__init__.py +42 -0
  97. maque/quantization/auto_round.py +120 -0
  98. maque/quantization/base.py +145 -0
  99. maque/quantization/bitsandbytes.py +127 -0
  100. maque/quantization/llm_compressor.py +102 -0
  101. maque/retriever/__init__.py +35 -0
  102. maque/retriever/chroma.py +654 -0
  103. maque/retriever/document.py +140 -0
  104. maque/retriever/milvus.py +1140 -0
  105. maque/table_ops/__init__.py +1 -0
  106. maque/table_ops/core.py +133 -0
  107. maque/table_viewer/__init__.py +4 -0
  108. maque/table_viewer/download_assets.py +57 -0
  109. maque/table_viewer/server.py +698 -0
  110. maque/table_viewer/static/element-plus-icons.js +5791 -0
  111. maque/table_viewer/static/element-plus.css +1 -0
  112. maque/table_viewer/static/element-plus.js +65236 -0
  113. maque/table_viewer/static/main.css +268 -0
  114. maque/table_viewer/static/main.js +669 -0
  115. maque/table_viewer/static/vue.global.js +18227 -0
  116. maque/table_viewer/templates/index.html +401 -0
  117. maque/utils/__init__.py +56 -0
  118. maque/utils/color.py +68 -0
  119. maque/utils/color_string.py +45 -0
  120. maque/utils/compress.py +66 -0
  121. maque/utils/constant.py +183 -0
  122. maque/utils/core.py +261 -0
  123. maque/utils/cursor.py +143 -0
  124. maque/utils/distance.py +58 -0
  125. maque/utils/docker.py +96 -0
  126. maque/utils/downloads.py +51 -0
  127. maque/utils/excel_helper.py +542 -0
  128. maque/utils/helper_metrics.py +121 -0
  129. maque/utils/helper_parser.py +168 -0
  130. maque/utils/net.py +64 -0
  131. maque/utils/nvidia_stat.py +140 -0
  132. maque/utils/ops.py +53 -0
  133. maque/utils/packages.py +31 -0
  134. maque/utils/path.py +57 -0
  135. maque/utils/tar.py +260 -0
  136. maque/utils/untar.py +129 -0
  137. maque/web/__init__.py +0 -0
  138. maque/web/image_downloader.py +1410 -0
  139. maque-0.2.1.dist-info/METADATA +450 -0
  140. maque-0.2.1.dist-info/RECORD +143 -0
  141. maque-0.2.1.dist-info/WHEEL +4 -0
  142. maque-0.2.1.dist-info/entry_points.txt +3 -0
  143. 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