dexverse-cli 0.0.2__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.
@@ -0,0 +1,449 @@
1
+ # ----------------------------------------------------------------------------
2
+ # Copyright (c) 2021-2025 DexForce Technology Co., Ltd.
3
+ #
4
+ # All rights reserved.
5
+ # ----------------------------------------------------------------------------
6
+ """image_video_encode.py — RGB / depth 图像与视频(MP4 / AVI)编解码工具。
7
+
8
+ 由 ``dexverse.convert.hdf5_to_raw`` 复用,提供把 MP4 字节流解码回 RGB 帧的
9
+ 能力(``decode_mp4_bytes_to_rgb_images``)。
10
+ """
11
+ import numpy as np
12
+ from PIL import Image
13
+ from typing import List, Optional
14
+ import av
15
+ from fractions import Fraction
16
+ import io
17
+ import os
18
+ from dataclasses import dataclass
19
+ import logging
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # List of hardware encoders to probe for auto-selection. Availability depends on the platform and FFmpeg build.
24
+ # Determines the order of preference for auto-selection when vcodec="auto" is used.
25
+ HW_ENCODERS = [
26
+ "h264_videotoolbox", # macOS
27
+ "hevc_videotoolbox", # macOS
28
+ "h264_nvenc", # NVIDIA GPU
29
+ "hevc_nvenc", # NVIDIA GPU
30
+ "h264_vaapi", # Linux Intel/AMD
31
+ "h264_qsv", # Intel Quick Sync
32
+ ]
33
+
34
+ VALID_VIDEO_CODECS = {"h264", "hevc", "libsvtav1", "auto"} | set(HW_ENCODERS)
35
+
36
+
37
+ def _get_codec_options(
38
+ vcodec: str,
39
+ g: Optional[int] = 2,
40
+ crf: Optional[int] = 30,
41
+ preset: Optional[int] = None,
42
+ ) -> dict:
43
+ """Build codec-specific options dict for video encoding."""
44
+ options = {}
45
+
46
+ # GOP size (keyframe interval) - supported by VideoToolbox and software encoders
47
+ if g is not None and (vcodec in ("h264_videotoolbox", "hevc_videotoolbox") or vcodec not in HW_ENCODERS):
48
+ options["g"] = str(g)
49
+
50
+ # Quality control (codec-specific parameter names)
51
+ if crf is not None:
52
+ if vcodec in ("h264", "hevc", "libsvtav1"):
53
+ options["crf"] = str(crf)
54
+ elif vcodec in ("h264_videotoolbox", "hevc_videotoolbox"):
55
+ quality = max(1, min(100, int(100 - crf * 2)))
56
+ options["q:v"] = str(quality)
57
+ elif vcodec in ("h264_nvenc", "hevc_nvenc"):
58
+ options["rc"] = "constqp"
59
+ options["qp"] = str(crf)
60
+ elif vcodec in ("h264_vaapi",):
61
+ options["qp"] = str(crf)
62
+ elif vcodec in ("h264_qsv",):
63
+ options["global_quality"] = str(crf)
64
+
65
+ # Preset (only for libsvtav1)
66
+ if vcodec == "libsvtav1":
67
+ options["preset"] = str(preset) if preset is not None else "12"
68
+
69
+ return options
70
+
71
+
72
+ def detect_available_hw_encoders() -> List[str]:
73
+ """Probe PyAV/FFmpeg for available hardware video encoders."""
74
+ available = []
75
+ for codec_name in HW_ENCODERS:
76
+ try:
77
+ av.codec.Codec(codec_name, "w")
78
+ available.append(codec_name)
79
+ except Exception: # nosec B110
80
+ logger.debug("HW encoder '%s' not available", codec_name) # nosec B110
81
+ return available
82
+
83
+
84
+ def resolve_vcodec(vcodec: str) -> str:
85
+ """Validate vcodec and resolve 'auto' to best available HW encoder, fallback to libsvtav1."""
86
+ if vcodec not in VALID_VIDEO_CODECS:
87
+ raise ValueError(f"Invalid vcodec '{vcodec}'. Must be one of: {sorted(VALID_VIDEO_CODECS)}")
88
+ if vcodec != "auto":
89
+ logger.info(f"Using video codec: {vcodec}")
90
+ return vcodec
91
+ available = detect_available_hw_encoders()
92
+ for encoder in HW_ENCODERS:
93
+ if encoder in available:
94
+ logger.info(f"Auto-selected video codec: {encoder}")
95
+ return encoder
96
+ logger.info("No hardware encoder available, falling back to software encoder 'libsvtav1'")
97
+ return "libsvtav1"
98
+
99
+
100
+ def encode_rgb_images_as_mp4_bytes_impl(
101
+ images: List['Image.Image'],
102
+ fps: int = 30,
103
+ vcodec: str = "libsvtav1",
104
+ pix_fmt: str = "yuv420p",
105
+ g: Optional[int] = 2,
106
+ crf: Optional[int] = 30,
107
+ fast_decode: int = 0,
108
+ log_level: Optional[int] = av.logging.WARNING,
109
+ preset: Optional[int] = None
110
+ ) -> Optional[bytes]:
111
+ if not images:
112
+ return None
113
+
114
+ # Resolve vcodec
115
+ vcodec = resolve_vcodec(vcodec)
116
+
117
+ # Get image size from the first image
118
+ width, height = images[0].size
119
+
120
+ # Encoders/pixel formats incompatibility check
121
+ if (vcodec == "libsvtav1" or vcodec == "hevc") and pix_fmt == "yuv444p":
122
+ logger.warning(
123
+ f"Incompatible pixel format 'yuv444p' for codec {vcodec}, auto-selecting format 'yuv420p'"
124
+ )
125
+ pix_fmt = "yuv420p"
126
+
127
+ # Define video codec options
128
+ video_options = _get_codec_options(vcodec, g, crf, preset)
129
+
130
+ if fast_decode:
131
+ key = "svtav1-params" if vcodec == "libsvtav1" else "tune"
132
+ value = f"fast-decode={fast_decode}" if vcodec == "libsvtav1" else "fastdecode"
133
+ video_options[key] = value
134
+
135
+ # Set logging level
136
+ if log_level is not None:
137
+ logging.getLogger("libav").setLevel(log_level)
138
+
139
+ # Create a BytesIO object to hold the MP4 data
140
+ output_buffer = io.BytesIO()
141
+
142
+ # Create container with the BytesIO object
143
+ container = av.open(output_buffer, 'w', format='mp4')
144
+
145
+ # Add video stream
146
+ stream = container.add_stream(vcodec, rate=fps, options=video_options)
147
+ stream.width = width
148
+ stream.height = height
149
+ stream.pix_fmt = pix_fmt
150
+ stream.time_base = Fraction(1, fps)
151
+
152
+ # Encode frames
153
+ for i, img in enumerate(images):
154
+ # Convert PIL Image to AV VideoFrame
155
+ frame = av.VideoFrame.from_image(img.convert('RGB'))
156
+ frame.pts = i
157
+ frame.time_base = Fraction(1, fps)
158
+
159
+ # Encode frame
160
+ for packet in stream.encode(frame):
161
+ container.mux(packet)
162
+
163
+ # Flush encoder
164
+ for packet in stream.encode():
165
+ container.mux(packet)
166
+
167
+ # Reset logging level
168
+ if log_level is not None:
169
+ av.logging.restore_default_callback()
170
+
171
+ # Close container to ensure all data is written
172
+ container.close()
173
+
174
+ # Get the bytes from the buffer
175
+ output_buffer.seek(0)
176
+ output_bytes = output_buffer.getvalue()
177
+
178
+ return output_bytes
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class EncodeImageIntoMp4Option(object):
183
+ fps: int = 30
184
+ vcodec: str = "libsvtav1"
185
+ pix_fmt: str = "yuv420p"
186
+ g: Optional[int] = 2
187
+ crf: Optional[int] = 30
188
+ fast_decode: int = 0
189
+ log_level: Optional[int] = av.logging.WARNING
190
+ preset: Optional[int] = None
191
+
192
+
193
+ def encode_rgb_images_as_mp4_bytes(
194
+ images: List['Image.Image'],
195
+ option_in: Optional[EncodeImageIntoMp4Option] = None
196
+ ) -> Optional[bytes]:
197
+ """Encode a list of RGB images into MP4 bytes.
198
+
199
+ Args:
200
+ images: List of PIL Image objects to encode.
201
+ option_in: Optional EncodeImageIntoMp4Option object with encoding parameters.
202
+ If None, default values will be used.
203
+
204
+ Returns:
205
+ MP4-encoded bytes, or None if no images were provided.
206
+ """
207
+ if option_in is None:
208
+ option_in = EncodeImageIntoMp4Option()
209
+
210
+ return encode_rgb_images_as_mp4_bytes_impl(
211
+ images=images,
212
+ fps=option_in.fps,
213
+ vcodec=option_in.vcodec,
214
+ pix_fmt=option_in.pix_fmt,
215
+ g=option_in.g,
216
+ crf=option_in.crf,
217
+ fast_decode=option_in.fast_decode,
218
+ log_level=option_in.log_level,
219
+ preset=option_in.preset
220
+ )
221
+
222
+
223
+ def decode_mp4_bytes_to_rgb_images(encoded_bytes: bytes) -> List['Image.Image']:
224
+ # Create a BytesIO object from the encoded bytes
225
+ input_buffer = io.BytesIO(encoded_bytes)
226
+
227
+ # Open the buffer as a container
228
+ container = av.open(input_buffer, 'r')
229
+
230
+ # Iterate through frames
231
+ images: List['Image.Image'] = list()
232
+ for frame in container.decode(video=0):
233
+ # Convert AV VideoFrame to PIL Image
234
+ img = frame.to_image()
235
+ images.append(img)
236
+
237
+ # Done
238
+ container.close()
239
+ return images
240
+
241
+
242
+ def encode_depth_images_as_avi_bytes_impl(
243
+ images: List[np.ndarray],
244
+ fps: int = 30,
245
+ vcodec: str = "ffv1",
246
+ pix_fmt: str = "gray16le",
247
+ log_level: Optional[int] = av.logging.WARNING,
248
+ ) -> Optional[bytes]:
249
+ """Encode a list of uint16 depth images into a video bytes.
250
+
251
+ Args:
252
+ images: List of uint16 numpy arrays (depth images).
253
+ fps: Frames per second for the video.
254
+ vcodec: Video codec to use. Default is 'ffv1' (lossless).
255
+ pix_fmt: Pixel format. Default is 'gray16le' (16-bit grayscale, little-endian).
256
+ log_level: PyAV logging level.
257
+
258
+ Returns:
259
+ Encoded video bytes, or None if no images were provided.
260
+ """
261
+ if images is None or len(images) == 0:
262
+ return None
263
+
264
+ # Check data type
265
+ for img in images:
266
+ if img.dtype != np.uint16:
267
+ return None
268
+
269
+ # Input checked
270
+ height, width = images[0].shape
271
+ if log_level is not None:
272
+ logging.getLogger("libav").setLevel(log_level)
273
+
274
+ output_buffer = io.BytesIO()
275
+ container = av.open(output_buffer, 'w', format='avi')
276
+
277
+ stream = container.add_stream(vcodec, rate=fps)
278
+ stream.width = width
279
+ stream.height = height
280
+ stream.pix_fmt = pix_fmt
281
+
282
+ for i, img in enumerate(images):
283
+ assert img.dtype == np.uint16
284
+ frame = av.VideoFrame.from_ndarray(img, format=pix_fmt)
285
+ frame.pts = i
286
+ frame.time_base = Fraction(1, fps)
287
+
288
+ for packet in stream.encode(frame):
289
+ container.mux(packet)
290
+
291
+ for packet in stream.encode():
292
+ container.mux(packet)
293
+
294
+ if log_level is not None:
295
+ av.logging.restore_default_callback()
296
+
297
+ container.close()
298
+ output_buffer.seek(0)
299
+ return output_buffer.getvalue()
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class EncodeDepthImageIntoAVIOption(object):
304
+ fps: int = 30
305
+ vcodec: str = "ffv1"
306
+ pix_fmt: str = "gray16le"
307
+ log_level: Optional[int] = av.logging.WARNING
308
+
309
+
310
+ def encode_depth_images_as_avi_bytes(
311
+ images: List[np.ndarray],
312
+ option_in: Optional[EncodeDepthImageIntoAVIOption] = None) -> Optional[bytes]:
313
+ """Encode a list of depht images into avi bytes.
314
+
315
+ Args:
316
+ images: List of depth images objects to encode.
317
+ option_in: Optional EncodeDepthImageIntoAVIOption object with encoding parameters.
318
+ If None, default values will be used.
319
+
320
+ Returns:
321
+ avi-encoded bytes, or None if no images were provided.
322
+ """
323
+ if option_in is None:
324
+ option_in = EncodeDepthImageIntoAVIOption()
325
+ return encode_depth_images_as_avi_bytes_impl(images=images, fps=option_in.fps, vcodec=option_in.vcodec,
326
+ pix_fmt=option_in.pix_fmt, log_level=option_in.log_level)
327
+
328
+
329
+ def encode_metric_depth_images_as_avi_bytes(
330
+ images: List[np.ndarray],
331
+ option_in: Optional[EncodeDepthImageIntoAVIOption] = None) -> Optional[bytes]:
332
+ # Convert the input
333
+ depth_images_uint16: List[np.ndarray] = list()
334
+ for img in images:
335
+ if img.dtype == np.float32:
336
+ img_mm = img * 1000.0
337
+ depth_images_uint16.append(img_mm.astype(np.uint16))
338
+ elif img.dtype == np.uint16:
339
+ depth_images_uint16.append(img)
340
+ else:
341
+ return None
342
+
343
+ return encode_depth_images_as_avi_bytes(depth_images_uint16, option_in)
344
+
345
+
346
+ def decode_avi_bytes_to_depth_images(
347
+ encoded_bytes: bytes,
348
+ pix_fmt: str = "gray16le",
349
+ ) -> List[np.ndarray]:
350
+ """Decode video bytes back to uint16 depth images.
351
+
352
+ Args:
353
+ encoded_bytes: Encoded video bytes.
354
+ pix_fmt: Pixel format used during encoding. Default is 'gray16le'.
355
+
356
+ Returns:
357
+ List of uint16 numpy arrays (depth images).
358
+ """
359
+ # Make input buffer
360
+ input_buffer = io.BytesIO(encoded_bytes)
361
+ container = av.open(input_buffer, 'r')
362
+
363
+ # Load the images
364
+ images: List[np.ndarray] = []
365
+ for frame in container.decode(video=0):
366
+ img = frame.to_ndarray(format=pix_fmt)
367
+ images.append(img)
368
+
369
+ # Done
370
+ container.close()
371
+ return images
372
+
373
+
374
+ def encode_rgb_image_path_into_mp4(
375
+ image_path: List[str],
376
+ mp4_output_path: str,
377
+ option_in: Optional[EncodeImageIntoMp4Option] = None):
378
+ """Encode images from file paths into an MP4 file.
379
+
380
+ Args:
381
+ image_path: List of paths to image files.
382
+ mp4_output_path: Path to save the output MP4 file.
383
+ option_in: Optional EncodeImageIntoMp4Option object with encoding parameters.
384
+ If None, default values will be used.
385
+ """
386
+ # Read images from file paths
387
+ images = []
388
+ for path in image_path:
389
+ with Image.open(path) as img:
390
+ images.append(img.convert('RGB'))
391
+
392
+ # Encode images to MP4 bytes
393
+ encoded_bytes = encode_rgb_images_as_mp4_bytes(images, option_in)
394
+
395
+ if encoded_bytes is None:
396
+ logger.warning("No images to encode")
397
+ return
398
+
399
+ # Create the output directory if it doesn't exist
400
+ output_dir = os.path.dirname(mp4_output_path)
401
+ if output_dir:
402
+ os.makedirs(output_dir, exist_ok=True)
403
+
404
+ # Write the MP4 bytes to file
405
+ with open(mp4_output_path, 'wb') as f:
406
+ f.write(encoded_bytes)
407
+
408
+ logger.info(f"Encoded {len(image_path)} images into {mp4_output_path}")
409
+
410
+
411
+ def decode_mp4_file_into_images_directory(mp4_file_path: str, save_image_dir: str):
412
+ """Decode an MP4 file into a directory of images.
413
+
414
+ Args:
415
+ mp4_file_path: Path to the MP4 file to decode.
416
+ save_image_dir: Directory to save the decoded images.
417
+ """
418
+ # Create the save directory if it doesn't exist
419
+ os.makedirs(save_image_dir, exist_ok=True)
420
+
421
+ # Read the MP4 file as bytes
422
+ with open(mp4_file_path, 'rb') as f:
423
+ mp4_bytes = f.read()
424
+
425
+ # Decode the MP4 bytes to images
426
+ images = decode_mp4_bytes_to_rgb_images(mp4_bytes)
427
+
428
+ # Save each image with a sequential filename
429
+ for i, img in enumerate(images):
430
+ img_path = os.path.join(save_image_dir, f"frame_{i:06d}.png")
431
+ img.save(img_path)
432
+
433
+ # Done
434
+ logger.info(f"Decoded MP4 file {mp4_file_path} into {save_image_dir}")
435
+
436
+
437
+ __all__ = [
438
+ "encode_rgb_images_as_mp4_bytes_impl",
439
+ "EncodeImageIntoMp4Option",
440
+ "encode_rgb_images_as_mp4_bytes",
441
+ "decode_mp4_bytes_to_rgb_images",
442
+ "encode_depth_images_as_avi_bytes_impl",
443
+ "EncodeDepthImageIntoAVIOption",
444
+ "encode_depth_images_as_avi_bytes",
445
+ "encode_metric_depth_images_as_avi_bytes",
446
+ "decode_avi_bytes_to_depth_images",
447
+ "encode_rgb_image_path_into_mp4",
448
+ "decode_mp4_file_into_images_directory",
449
+ ]
@@ -0,0 +1,26 @@
1
+ """Download core: manager, backends, verify, state."""
2
+ from __future__ import annotations
3
+
4
+ from .manager import DownloadManager, DownloadReport, get_backend, repair
5
+ from .base import Backend, Credentials, FileResult
6
+ from .s5cmd_backend import S5CmdBackend, S5CmdNotAvailable, generate_s5cmd_lines
7
+ from .python_s3_backend import PythonS3Backend
8
+ from .http_backend import HttpBackend
9
+ from .verify import verify_files, summarize
10
+
11
+ __all__ = [
12
+ "DownloadManager",
13
+ "DownloadReport",
14
+ "get_backend",
15
+ "repair",
16
+ "Backend",
17
+ "Credentials",
18
+ "FileResult",
19
+ "S5CmdBackend",
20
+ "S5CmdNotAvailable",
21
+ "generate_s5cmd_lines",
22
+ "PythonS3Backend",
23
+ "HttpBackend",
24
+ "verify_files",
25
+ "summarize",
26
+ ]
@@ -0,0 +1,101 @@
1
+ """Backend contract + shared credential resolution.
2
+
3
+ A *backend* takes the manifest, the subset of files that still need fetching,
4
+ the out root, and returns a per-object success/failure outcome. The download
5
+ manager owns skip/resume/verify; backends only move bytes.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Protocol
13
+
14
+ from ..manifest import FileSpec, Manifest
15
+
16
+
17
+ @dataclass
18
+ class Credentials:
19
+ access_key: Optional[str]
20
+ secret_key: Optional[str]
21
+ session_token: Optional[str]
22
+ region: Optional[str]
23
+ endpoint: Optional[str]
24
+
25
+ @classmethod
26
+ def from_manifest(cls, manifest: Manifest) -> "Credentials":
27
+ """Prefer manifest credentials; fall back to AWS_* env vars."""
28
+ storage = manifest.storage
29
+ endpoint = storage.endpoint if storage else None
30
+ region = (storage.region if storage else None) or os.getenv("AWS_REGION") \
31
+ or os.getenv("AWS_DEFAULT_REGION") or "us-east-1"
32
+
33
+ ak = sk = st = None
34
+ if storage and storage.credentials and storage.credentials.is_populated():
35
+ ak = storage.credentials.accessKey
36
+ sk = storage.credentials.secretKey
37
+ st = storage.credentials.sessionToken or None
38
+ else:
39
+ ak = os.getenv("AWS_ACCESS_KEY_ID")
40
+ sk = os.getenv("AWS_SECRET_ACCESS_KEY")
41
+ st = os.getenv("AWS_SESSION_TOKEN") or None
42
+
43
+ return cls(
44
+ access_key=ak, secret_key=sk, session_token=st,
45
+ region=region, endpoint=endpoint,
46
+ )
47
+
48
+ def as_env(self) -> Dict[str, str]:
49
+ """A dict suitable for merging into a subprocess environment."""
50
+ env: Dict[str, str] = {}
51
+ if self.access_key:
52
+ env["AWS_ACCESS_KEY_ID"] = self.access_key
53
+ if self.secret_key:
54
+ env["AWS_SECRET_ACCESS_KEY"] = self.secret_key
55
+ if self.session_token:
56
+ env["AWS_SESSION_TOKEN"] = self.session_token
57
+ if self.region:
58
+ env["AWS_REGION"] = self.region
59
+ env["AWS_DEFAULT_REGION"] = self.region
60
+ return env
61
+
62
+
63
+ @dataclass
64
+ class FileResult:
65
+ objectKey: str
66
+ ok: bool
67
+ error: Optional[str] = None
68
+ localSize: Optional[int] = None
69
+ expectedSize: Optional[int] = None
70
+
71
+
72
+ class ProgressReporter(Protocol):
73
+ """Optional byte-progress sink supplied by interactive callers."""
74
+
75
+ def start(self, *, total_bytes: Optional[int], total_files: int) -> None:
76
+ ...
77
+
78
+ def advance(self, bytes_count: int) -> None:
79
+ ...
80
+
81
+ def complete_file(self) -> None:
82
+ ...
83
+
84
+ def finish(self) -> None:
85
+ ...
86
+
87
+
88
+ class Backend(Protocol):
89
+ name: str
90
+
91
+ def download(
92
+ self,
93
+ manifest: Manifest,
94
+ files: List[FileSpec],
95
+ out: Path,
96
+ *,
97
+ parallel: int,
98
+ log_path: Optional[Path] = None,
99
+ progress: Optional[ProgressReporter] = None,
100
+ ) -> Dict[str, FileResult]:
101
+ ...