263 lines
8.3 KiB
Python
263 lines
8.3 KiB
Python
from pathlib import Path
|
|
|
|
import bpy
|
|
|
|
from ..image import force_sequence_source, pack_sequence_if_possible
|
|
DEPTH_KEY_CANDIDATES = ("depth", "depths")
|
|
INTRINSICS_KEY_CANDIDATES = ("intrinsics", "ixts")
|
|
|
|
def create_depth_sequence_image(
|
|
name,
|
|
depth_frames,
|
|
scene,
|
|
frame_directory,
|
|
frame_prefix="depth",
|
|
):
|
|
"""Write one EXR per depth frame and return a SEQUENCE image datablock."""
|
|
import numpy as np
|
|
|
|
frame_directory = Path(frame_directory)
|
|
frame_directory.mkdir(parents=True, exist_ok=True)
|
|
height, width = depth_frames[0].shape
|
|
image = bpy.data.images.new(
|
|
name,
|
|
width=width,
|
|
height=height,
|
|
alpha=False,
|
|
float_buffer=True,
|
|
)
|
|
image.colorspace_settings.name = "Non-Color"
|
|
image_settings = scene.render.image_settings
|
|
original_file_format = image_settings.file_format
|
|
frame_paths = []
|
|
try:
|
|
image_settings.file_format = "OPEN_EXR"
|
|
for frame_index, depth_values in enumerate(depth_frames):
|
|
# NPZ arrays use a top-left origin, while Blender image pixels start below.
|
|
depth_pixels = np.flipud(depth_values)
|
|
rgba = np.empty((height, width, 4), dtype=np.float32)
|
|
rgba[..., 0] = depth_pixels
|
|
rgba[..., 1] = depth_pixels
|
|
rgba[..., 2] = depth_pixels
|
|
rgba[..., 3] = 1.0
|
|
image.pixels.foreach_set(rgba.ravel())
|
|
frame_path = (
|
|
frame_directory / f"{frame_prefix}.{frame_index + 1:04d}.exr"
|
|
)
|
|
image.save_render(str(frame_path), scene=scene)
|
|
if not frame_path.is_file():
|
|
raise RuntimeError(f"Blender did not write: {frame_path}")
|
|
frame_paths.append(frame_path)
|
|
finally:
|
|
image_settings.file_format = original_file_format
|
|
bpy.data.images.remove(image, do_unlink=True)
|
|
if not frame_paths:
|
|
raise RuntimeError("Unable to write the depth frame sequence")
|
|
try:
|
|
sequence_image = bpy.data.images.load(
|
|
str(frame_paths[0]),
|
|
check_existing=False,
|
|
)
|
|
force_sequence_source(sequence_image)
|
|
except (OSError, RuntimeError) as error:
|
|
if "sequence_image" in locals():
|
|
bpy.data.images.remove(sequence_image, do_unlink=True)
|
|
raise RuntimeError(
|
|
"Unable to load the depth frame sequence"
|
|
) from error
|
|
sequence_image.name = name
|
|
sequence_image.colorspace_settings.name = "Non-Color"
|
|
pack_sequence_if_possible(sequence_image, len(frame_paths))
|
|
return sequence_image
|
|
|
|
|
|
|
|
def scale_depth_values(depth, depth_scale):
|
|
return prepare_depth_values(depth) * depth_scale
|
|
|
|
|
|
def extract_depth_arrays(npz_path):
|
|
try:
|
|
import numpy as np
|
|
except ImportError as error:
|
|
raise RuntimeError("Blender Python does not provide NumPy") from error
|
|
|
|
try:
|
|
with np.load(npz_path, allow_pickle=False) as archive:
|
|
depth_key = next(
|
|
(
|
|
key
|
|
for key in DEPTH_KEY_CANDIDATES
|
|
if key in archive.files
|
|
),
|
|
None,
|
|
)
|
|
if depth_key is None:
|
|
keys = ", ".join(archive.files) or "none"
|
|
raise ValueError(
|
|
f"Mini NPZ has no depth array; available keys: {keys}"
|
|
)
|
|
depth = np.asarray(archive[depth_key], dtype=np.float32)
|
|
except OSError as error:
|
|
raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error
|
|
|
|
if depth.ndim >= 3 and depth.shape[-1] == 1:
|
|
depth = depth[..., 0]
|
|
if depth.ndim == 2:
|
|
depth = depth[None, ...]
|
|
if depth.ndim != 3:
|
|
raise ValueError(
|
|
f"Unsupported depth array shape: {tuple(depth.shape)}"
|
|
)
|
|
return [depth[index] for index in range(depth.shape[0])]
|
|
|
|
|
|
def extract_depth_array(npz_path):
|
|
return extract_depth_arrays(npz_path)[0]
|
|
|
|
|
|
def extract_intrinsics_array(npz_path):
|
|
try:
|
|
import numpy as np
|
|
except ImportError as error:
|
|
raise RuntimeError("Blender Python does not provide NumPy") from error
|
|
|
|
try:
|
|
with np.load(npz_path, allow_pickle=False) as archive:
|
|
intrinsics_key = next(
|
|
(
|
|
key
|
|
for key in INTRINSICS_KEY_CANDIDATES
|
|
if key in archive.files
|
|
),
|
|
None,
|
|
)
|
|
if intrinsics_key is None:
|
|
keys = ", ".join(archive.files) or "none"
|
|
raise ValueError(
|
|
"Mini NPZ has no camera intrinsics; "
|
|
f"available keys: {keys}"
|
|
)
|
|
intrinsics = np.asarray(
|
|
archive[intrinsics_key],
|
|
dtype=np.float32,
|
|
)
|
|
except OSError as error:
|
|
raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error
|
|
|
|
if intrinsics.ndim == 3:
|
|
intrinsics = intrinsics[0]
|
|
if intrinsics.shape != (3, 3):
|
|
raise ValueError(
|
|
f"Unsupported intrinsics shape: {tuple(intrinsics.shape)}"
|
|
)
|
|
return intrinsics
|
|
|
|
|
|
def prepare_depth_values(depth):
|
|
import numpy as np
|
|
|
|
valid = np.isfinite(depth)
|
|
if not valid.any():
|
|
raise ValueError("Depth array contains no finite values")
|
|
values = depth.astype(np.float32, copy=True)
|
|
values[~valid] = 0.0
|
|
return values
|
|
|
|
|
|
def depth_displacement_values(depth):
|
|
"""Convert raw DA3 camera distance to positive outward displacement."""
|
|
import numpy as np
|
|
|
|
return 1.0 - np.asarray(depth, dtype=np.float32)
|
|
|
|
|
|
def camera_plane_bounds(
|
|
source_object,
|
|
depth,
|
|
intrinsics,
|
|
displacement_strength,
|
|
reference_depth=None,
|
|
):
|
|
import numpy as np
|
|
|
|
scale = abs(float(displacement_strength))
|
|
if scale <= 1e-8:
|
|
raise ValueError("Displacement Strength must not be zero")
|
|
|
|
if reference_depth is None:
|
|
valid = np.isfinite(depth) & (depth > 0.0)
|
|
if not valid.any():
|
|
raise ValueError("Depth array contains no positive finite values")
|
|
reference_depth = float(np.median(depth[valid]))
|
|
reference_distance = reference_depth * scale
|
|
|
|
height, width = depth.shape
|
|
fx = float(intrinsics[0, 0])
|
|
fy = float(intrinsics[1, 1])
|
|
cx = float(intrinsics[0, 2])
|
|
cy = float(intrinsics[1, 2])
|
|
if fx <= 0.0 or fy <= 0.0:
|
|
raise ValueError("Camera intrinsics contain an invalid focal length")
|
|
|
|
source_width, source_height = source_object.data.size
|
|
if source_width <= 0 or source_height <= 0:
|
|
raise ValueError("The source image has no readable dimensions")
|
|
source_aspect = source_width / source_height
|
|
|
|
# Predicted fx/fy may differ even for square pixels. Preserve their projected
|
|
# area while enforcing the source image aspect ratio to avoid image stretch.
|
|
raw_width = width * reference_distance / fx
|
|
raw_height = height * reference_distance / fy
|
|
plane_area = raw_width * raw_height
|
|
plane_width = float(np.sqrt(plane_area * source_aspect))
|
|
plane_height = float(np.sqrt(plane_area / source_aspect))
|
|
|
|
x_min = -(cx / width) * plane_width
|
|
x_max = (1.0 - cx / width) * plane_width
|
|
y_min = -(1.0 - cy / height) * plane_height
|
|
y_max = (cy / height) * plane_height
|
|
|
|
# Preserve an intentional Image Empty origin offset around the camera frame.
|
|
offset = source_object.empty_image_offset
|
|
x_offset = (float(offset[0]) + 0.5) * plane_width
|
|
y_offset = (float(offset[1]) + 0.5) * plane_height
|
|
return (
|
|
(
|
|
x_min + x_offset,
|
|
x_max + x_offset,
|
|
y_min + y_offset,
|
|
y_max + y_offset,
|
|
),
|
|
reference_depth,
|
|
)
|
|
|
|
|
|
def create_depth_image(name, depth_values):
|
|
import numpy as np
|
|
|
|
height, width = depth_values.shape
|
|
image = bpy.data.images.new(
|
|
name,
|
|
width=width,
|
|
height=height,
|
|
alpha=False,
|
|
float_buffer=True,
|
|
)
|
|
image.colorspace_settings.name = "Non-Color"
|
|
image.file_format = "OPEN_EXR"
|
|
|
|
# NPZ arrays use a top-left origin, while Blender image pixels start below.
|
|
depth_pixels = np.flipud(depth_values)
|
|
rgba = np.empty((height, width, 4), dtype=np.float32)
|
|
rgba[..., 0] = depth_pixels
|
|
rgba[..., 1] = depth_pixels
|
|
rgba[..., 2] = depth_pixels
|
|
rgba[..., 3] = 1.0
|
|
image.pixels.foreach_set(rgba.ravel())
|
|
# Pack as EXR so values outside 0..1 survive saving and reopening the blend.
|
|
image.pack()
|
|
return image
|
|
|
|
|