Add new tests and functionality for image processing and geometry handling

- Introduced color_image.py for preparing standalone color images for tests.
- Added hdr_image.py to create HDR textures for testing.
- Implemented test_image_depth_panorama.py to evaluate panorama topology, UVs, and depth discontinuities.
- Created test_shadeless.py to validate the opacity and HDR color endpoints of the Shadeless group.
- Developed moge3_export.py for exporting dynamic MoGe-3 backbone and ONNX graphs.
- Added material.py to inherit material slots onto generated geometry.
- Built image_depth_panorama.py to construct a quad panorama with depth separation and sampling.
This commit is contained in:
2026-09-10 14:42:17 +08:00
parent 27ca25224a
commit 489003c2cc
136 changed files with 5758 additions and 1083 deletions

View File

@@ -1,9 +1,9 @@
"""Shared Blender-side AI setup, validation, UI, and Job parameters."""
from pathlib import Path
import bpy
from ..server.media.input import validate_input_path
from ..preferences import addon_preferences
from ..properties import (
ai_ready,
@@ -14,11 +14,6 @@ from ..properties import (
)
from ..runtime import runtime
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
BEN2_VIDEO_SUFFIXES = {
".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".mts", ".mpg", ".mpeg"
}
def draw_ai_setup(layout):
status = ai_status()
@@ -38,21 +33,12 @@ def draw_ai_property(layout, owner, name, status):
def require_input_path(value):
path = Path(bpy.path.abspath(value))
if path.is_file() or path.is_dir():
return path
raise ValueError("Select a valid image, video, or frame directory")
return validate_input_path(bpy.path.abspath(value))
def require_background_input_path(value):
path = require_input_path(value)
if path.is_dir() or path.suffix.lower() in (
BEN2_IMAGE_SUFFIXES | BEN2_VIDEO_SUFFIXES
):
return path
raise ValueError(
"BEN2 requires an image, video, or directory of image frames"
)
def report_ai_error(operator, error):
"""Report the underlying reason from a nested Blender operator."""
operator.report({"ERROR"}, str(error).removeprefix("Error: ").strip())
def production_device():
@@ -64,10 +50,14 @@ def require_environment():
raise RuntimeError("Install the AnyImage Environment first")
def invoke_ai_setup_if_needed():
def invoke_ai_setup_if_needed(operator):
if ai_ready():
return None
return bpy.ops.anyimage.setup_ai_environment("INVOKE_DEFAULT")
try:
return bpy.ops.anyimage.setup_ai_environment("INVOKE_DEFAULT")
except RuntimeError as error:
report_ai_error(operator, error)
return {"CANCELLED"}
def require_model(model_key):

View File

@@ -0,0 +1,139 @@
"""Prepare isolated material color files from static image bounds."""
import json
import tempfile
from contextlib import contextmanager
from pathlib import Path
import bpy
from .image import cleanup_image_input, image_base_name, image_is_packed, image_rgba, is_animated_image, video_total_frames
def require_static_color_image(image):
"""Reject animated inputs before preparing material resources."""
if is_animated_image(image) or video_total_frames(image) != 1:
raise ValueError("Material conversion supports static images only")
def _save_byte_color(rgba, path, color_space):
import numpy as np
height, width = rgba.shape[:2]
image = bpy.data.images.new("Material Color Export", width=width, height=height, alpha=True)
try:
image.colorspace_settings.name = color_space
image.alpha_mode = "STRAIGHT"
image.pixels.foreach_set(np.flipud(rgba).ravel())
image.file_format = "PNG"
image.save(filepath=str(path), save_copy=True)
if not path.is_file():
raise RuntimeError("Unable to export the material color image")
finally:
bpy.data.images.remove(image)
def prepare_material_color_input(source, bounds=None, rgba=None):
"""Export source bounds with interpretation matching the encoded color file."""
import numpy as np
from .hdr_image import save_hdr_result
require_static_color_image(source)
width, height = map(int, source.size)
if (source.is_float and source.source == "FILE" and not source.is_dirty
and not image_is_packed(source) and source.alpha_mode == "STRAIGHT"):
# Read the encoded float colors without applying a second alpha multiplication.
reader = source.copy()
try:
reader.alpha_mode = "PREMUL"
rgba = image_rgba(reader)
finally:
bpy.data.images.remove(reader)
else:
rgba = image_rgba(source) if rgba is None else np.asarray(rgba, dtype=np.float32)
if rgba.shape != (height, width, 4):
raise ValueError("The source RGBA does not match the Blender Image")
left, top, right, bottom = (0, 0, width, height) if bounds is None else map(int, bounds)
if not (0 <= left < right <= width and 0 <= top < bottom <= height):
raise ValueError("The material color bounds are outside the image")
cropped = rgba[top:bottom, left:right]
suffix = ".exr" if source.is_float else ".png"
path = Path(tempfile.mkdtemp(prefix="anyimage-color-")) / ("color" + suffix)
try:
if source.is_float:
# Store float colors directly; the EXR interpretation is applied on load.
save_hdr_result(cropped, path)
else:
_save_byte_color(cropped, path, source.colorspace_settings.name)
path.with_suffix(".json").write_text(json.dumps({
"name": image_base_name(source) + "_color" + suffix,
"color_space": "Linear Rec.709" if source.is_float else source.colorspace_settings.name,
"alpha_mode": "PREMUL" if source.is_float and source.alpha_mode == "STRAIGHT" else source.alpha_mode,
}), encoding="utf-8")
return path
except Exception:
cleanup_material_color_input(path)
raise
def material_analysis_input(color_path):
"""Return the byte color file or an HDR recognition preview in the same directory."""
from .hdr_image import recognition_rgba
path = Path(color_path)
if path.suffix == ".png":
return path
image = load_material_color_image(path)
try:
rgba = image_rgba(image)
if image.alpha_mode == "NONE":
rgba[..., 3] = 1
preview = recognition_rgba(rgba, associated=image.alpha_mode not in {"CHANNEL_PACKED", "NONE"})
output = path.parent / "analysis.png"
_save_byte_color(preview, output, "sRGB")
return output
finally:
bpy.data.images.remove(image)
def cleanup_material_color_input(path):
"""Release the directory owned by a prepared material color input."""
if path:
cleanup_image_input(Path(path).parent, True)
def load_material_color_image(path):
"""Load an independent packed Color image using its file interpretation."""
path = Path(path)
metadata = json.loads(path.with_suffix(".json").read_text(encoding="utf-8"))
image = bpy.data.images.load(str(path), check_existing=False)
try:
image.name = metadata["name"]
image.colorspace_settings.name = metadata["color_space"]
image.alpha_mode = metadata["alpha_mode"]
image.pack()
return image
except Exception:
bpy.data.images.remove(image)
raise
@contextmanager
def material_color_image(source=None, *, bounds=None, rgba=None, input_path=None):
"""Own a prepared color image through material creation and clean failed results."""
owned = input_path is None
path = prepare_material_color_input(source, bounds, rgba) if owned else Path(input_path)
image = None
try:
image = load_material_color_image(path)
yield image
except Exception:
if image is not None:
bpy.data.images.remove(image, do_unlink=True)
image = None
raise
finally:
if image is not None and image.users == 0:
bpy.data.images.remove(image)
if owned:
cleanup_material_color_input(path)

View File

@@ -0,0 +1,177 @@
"""Prepare HDR recognition previews and commit float background-removal results."""
from dataclasses import dataclass
from pathlib import Path
import tempfile
import bpy
import numpy as np
from .image import (
cleanup_image_input, image_content_state, image_rgba, is_animated_image,
restore_image_content,
)
from .image_target import has_other_image_user
def recognition_rgba(rgba, *, associated=True):
"""Tone-map scene-linear pixels for sRGB recognition without changing the source."""
alpha = rgba[..., 3:4]
rgb = rgba[..., :3].astype(np.float64)
if associated:
rgb = np.divide(rgb, alpha, out=np.zeros_like(rgb), where=alpha > 0)
rgb = np.maximum(rgb, 0)
luminance = rgb @ np.array([0.2126, 0.7152, 0.0722])
rgb /= 1 + luminance[..., None]
encoded = np.where(rgb <= 0.0031308, rgb * 12.92, 1.055 * rgb ** (1 / 2.4) - 0.055)
preview = np.ones(rgba.shape, dtype=np.float32)
preview[..., :3] = np.clip(encoded, 0, 1) * alpha
return preview
def save_hdr_result(rgba, path):
"""Save associated scene-linear RGBA as ZIP-compressed 32-bit EXR."""
height, width = rgba.shape[:2]
image = bpy.data.images.new("AnyImage HDR Result", width=width, height=height,
alpha=True, float_buffer=True)
scene = None
try:
image.alpha_mode = "PREMUL"
image.colorspace_settings.name = "Linear Rec.709"
image.pixels.foreach_set(np.flipud(rgba).ravel())
scene = bpy.data.scenes.new("AnyImage HDR Export")
settings = scene.render.image_settings
settings.file_format = "OPEN_EXR"
settings.color_mode = "RGBA"
settings.color_depth = "32"
settings.exr_codec = "ZIP"
image.save_render(str(path), scene=scene)
finally:
if scene is not None:
bpy.data.scenes.remove(scene)
bpy.data.images.remove(image)
def create_packed_hdr(rgba, name, path, *, alpha_mode="PREMUL"):
"""Create a self-contained float image and verify its encoded pixels."""
result = None
save_hdr_result(rgba, path)
try:
result = bpy.data.images.load(str(path), check_existing=False)
result.alpha_mode = alpha_mode
if (not result.is_float
or result.colorspace_settings.name != "Linear Rec.709"
or not np.allclose(image_rgba(result), rgba, rtol=2e-6, atol=1e-7)):
raise RuntimeError("The HDR result did not preserve float colors")
result.pack()
result.name = name
return result
except Exception:
if result is not None:
bpy.data.images.remove(result)
raise
@dataclass
class HdrBackgroundInput:
"""Own the submitted pixels and color interpretation of an HDR still."""
rgba: np.ndarray
color_space: str
alpha_mode: str
source: str
path: Path
@classmethod
def prepare(cls, image):
if is_animated_image(image):
raise RuntimeError("HDR background removal supports still images only")
if image.source not in {"FILE", "GENERATED"}:
raise RuntimeError("HDR background removal requires a file or generated image")
if image.colorspace_settings.is_data:
raise RuntimeError("HDR background removal requires a color image with opacity alpha")
rgba = image_rgba(image).copy()
if not np.isfinite(rgba).all() or np.any((rgba[..., 3] < 0) | (rgba[..., 3] > 1)):
raise RuntimeError("HDR image contains invalid color or alpha values")
path = Path(tempfile.mkdtemp(prefix="anyimage-hdr-")) / "input.png"
preview = None
try:
height, width = rgba.shape[:2]
preview = bpy.data.images.new("AnyImage HDR Preview", width=width, height=height,
alpha=True)
preview.colorspace_settings.name = "sRGB"
preview.alpha_mode = "STRAIGHT"
preview_source = rgba.copy()
if image.alpha_mode == "NONE":
preview_source[..., 3] = 1
preview_rgba = recognition_rgba(
preview_source, associated=image.alpha_mode not in {"CHANNEL_PACKED", "NONE"},
)
preview.pixels.foreach_set(np.flipud(preview_rgba).ravel())
preview.file_format = "PNG"
preview.save(filepath=str(path), save_copy=True)
if not path.is_file():
raise RuntimeError("Blender did not export the HDR recognition preview")
except Exception:
cleanup_image_input(path, True)
raise
finally:
if preview is not None:
bpy.data.images.remove(preview)
return cls(rgba, image.colorspace_settings.name, image.alpha_mode, image.source, path)
def apply(self, target, alpha_path, *, prepare_undo=False):
target.validate()
image = target.image
if (not image.is_float or image.colorspace_settings.name != self.color_space
or image.alpha_mode != self.alpha_mode or image.source != self.source
or not np.array_equal(image_rgba(image), self.rgba)):
raise RuntimeError("The source HDR image changed; run Remove Background again")
try:
alpha = np.load(alpha_path, allow_pickle=False)
except (OSError, ValueError, EOFError) as error:
raise RuntimeError("Unable to read the background alpha result") from error
if not isinstance(alpha, np.ndarray):
alpha.close()
raise RuntimeError("Invalid background alpha result")
if (alpha.dtype != np.float32 or alpha.shape != self.rgba.shape[:2]
or not np.isfinite(alpha).all() or np.any((alpha < 0) | (alpha > 1))):
raise RuntimeError("Invalid background alpha result")
# Keep RGB independent of the new mask, including completely transparent pixels.
rgba = self.rgba.copy()
if self.alpha_mode == "NONE":
rgba[..., 3] = 1
rgba[..., 3] *= alpha
with tempfile.TemporaryDirectory(prefix="anyimage-hdr-result-") as directory:
result = create_packed_hdr(
rgba, image.name, Path(directory) / "result.exr", alpha_mode="CHANNEL_PACKED",
)
original_state = None
try:
if (prepare_undo and (image.is_dirty or image.source == "GENERATED")
and not has_other_image_user(target.owner)):
original_state = image_content_state(image)
baseline_rgba = self.rgba.copy()
if self.alpha_mode == "NONE":
baseline_rgba[..., 3] = 1
baseline = create_packed_hdr(
baseline_rgba, image.name, Path(directory) / "source.exr",
alpha_mode="CHANNEL_PACKED" if self.alpha_mode == "CHANNEL_PACKED" else "PREMUL",
)
try:
restore_image_content(image, image_content_state(baseline))
finally:
bpy.data.images.remove(baseline)
if "FINISHED" not in bpy.ops.ed.undo_push(message="Before HDR Background Removal"):
raise RuntimeError("Unable to preserve the source HDR image for Undo")
except Exception:
bpy.data.images.remove(result)
if original_state is not None:
restore_image_content(image, original_state)
raise
try:
return target.commit(result, isolate_shared=True)
except Exception:
if original_state is not None:
restore_image_content(image, original_state)
raise

View File

@@ -8,6 +8,8 @@ from pathlib import Path
import bpy
from ..server.media.input import validate_input_path
VIEW_TRANSFORM_COLOR_SPACES = (
("ACES 1.3", "ACES 1.3 sRGB"),
("ACES 2.0", "ACES 2.0 sRGB"),
@@ -333,11 +335,6 @@ def extract_frame_range(image, image_user):
return start_frame, frame_count
def source_start_frame(source_object_name):
source_object = bpy.data.objects.get(source_object_name)
return extract_frame_range(source_object.data, source_object.image_user)[0] if source_object else 1
def image_path(image):
filepath_from_user = getattr(image, "filepath_from_user", None)
filepath = (
@@ -395,22 +392,19 @@ def normal_data_name(source_object):
return f"{image_base_name(source_object.data)}_normal.png"
def color_data_name(source_object):
return f"{image_base_name(source_object.data)}_color.png"
def save_packed_image(image, _scene):
output_path = Path(tempfile.mkdtemp(prefix="anyimage-input-")) / "input.png"
original_file_format = image.file_format
try:
image.file_format = "PNG"
image.save(filepath=str(output_path), save_copy=True)
except (OSError, RuntimeError) as error:
output_path.unlink(missing_ok=True)
except (OSError, RuntimeError, ValueError) as error:
cleanup_image_input(output_path, True)
raise RuntimeError("Unable to export the packed image") from error
finally:
image.file_format = original_file_format
if not output_path.is_file():
cleanup_image_input(output_path, True)
raise RuntimeError("Blender did not export the packed image")
return output_path
@@ -450,9 +444,11 @@ def prepare_image_input(image, scene):
if getattr(image, "source", None) == "MOVIE":
if not source_path.is_file():
raise RuntimeError(
"The image empty must reference an existing video file"
f"Source video file does not exist: {source_path}"
)
return source_path, False
if getattr(image, "source", None) == "SEQUENCE":
validate_input_path(source_path, images_only=True)
sequence_frames = collect_sequence_frames(image)
if sequence_frames:
temporary_directory = Path(tempfile.mkdtemp(prefix="anyimage-input-"))
@@ -469,73 +465,11 @@ def prepare_image_input(image, scene):
return temporary_directory, True
if not source_path.is_file():
raise RuntimeError(
"The image empty must reference an existing image file"
f"Source image file does not exist: {source_path}"
)
return source_path, False
def prepare_image_bounds_input(image, source_rgba, bounds):
"""Write one rectangular RGBA input owned by its temporary directory."""
import numpy as np
left, top, right, bottom = (int(value) for value in bounds)
rgba = np.asarray(source_rgba, dtype=np.float32)
expected_shape = (int(image.size[1]), int(image.size[0]), 4)
if rgba.shape != expected_shape:
raise ValueError("The source RGBA does not match the Blender Image")
if (
left < 0
or top < 0
or right > expected_shape[1]
or bottom > expected_shape[0]
):
raise ValueError("The image bounds are outside the Blender Image")
if right <= left or bottom <= top:
raise ValueError("The image bounds are empty")
cropped = rgba[top:bottom, left:right]
output_directory = Path(tempfile.mkdtemp(prefix="anyimage-bounds-"))
output_path = output_directory / "input.png"
temporary_image = bpy.data.images.new(
"AnyImage Bounds Input",
width=right - left,
height=bottom - top,
alpha=True,
)
original_format = temporary_image.file_format
try:
temporary_image.colorspace_settings.name = image.colorspace_settings.name
temporary_image.alpha_mode = "STRAIGHT"
temporary_image.pixels.foreach_set(np.flipud(cropped).ravel())
temporary_image.file_format = "PNG"
temporary_image.save(filepath=str(output_path), save_copy=True)
except Exception:
cleanup_image_input(output_path, True)
raise
finally:
temporary_image.file_format = original_format
bpy.data.images.remove(temporary_image, do_unlink=True)
if not output_path.is_file():
cleanup_image_input(output_path, True)
raise RuntimeError("Blender did not export the bounds image input")
return output_path
def load_packed_rgba_image(path, source_image, name):
"""Load an encoded local RGBA file and make its Blender data self-contained."""
result_image = None
try:
result_image = bpy.data.images.load(str(path), check_existing=False)
result_image.colorspace_settings.name = source_image.colorspace_settings.name
result_image.alpha_mode = source_image.alpha_mode
result_image.name = name
result_image.pack()
except (OSError, RuntimeError, TypeError, ValueError) as error:
if result_image is not None:
bpy.data.images.remove(result_image, do_unlink=True)
raise RuntimeError("Unable to prepare the RGBA image result") from error
return result_image
def configure_image_user(
image_user,
frame_count=None,
@@ -674,6 +608,15 @@ def has_other_image_empty(source_object, *, removed_objects=()):
)
def require_conversion_source(operator):
"""Validate the object and image captured when a conversion was submitted."""
source = require_image_empty(operator.source_object_name)
if (str(source.as_pointer()) != operator.source_identity
or str(source.data.as_pointer()) != operator.image_identity):
raise ValueError("The conversion source image has changed")
return source
def image_content_state(image):
"""Capture encoded content and unsaved pixels for an Image transaction."""
import numpy as np
@@ -728,7 +671,7 @@ def restore_image_content(image, state):
def replace_empty_image(source_object, result_image, *, placement_bounds=None,
removed_objects=()):
removed_objects=(), isolate_image=False):
"""Replace one Empty image, retaining shared source images."""
try:
source_image = source_object.data
@@ -767,7 +710,7 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None,
)
original_content = None
try:
shared = has_other_image_empty(source_object, removed_objects=removed_objects)
shared = isolate_image or has_other_image_empty(source_object, removed_objects=removed_objects)
if shared:
final_image = result_image
else:
@@ -866,22 +809,6 @@ def load_image_edit_result(
)
def load_color_result_image(
source_object,
output_directory,
*,
frame_prefix="image.",
):
"""Load one packed Color texture result for a generated material."""
return _load_packed_result_image(
source_object.data,
output_directory,
frame_prefix=frame_prefix,
result_name=color_data_name(source_object),
alpha_mode=color_alpha_mode(source_object.data.colorspace_settings.name),
)
def image_empty_bounds(source_object):
image_width, image_height = source_object.data.size
if image_width <= 0 or image_height <= 0:

View File

@@ -1,6 +1,7 @@
"""Resolve and commit AI image edits for Empty objects and texture nodes."""
from dataclasses import dataclass
from uuid import uuid4
import bpy
@@ -56,16 +57,18 @@ def owner_image(owner):
return owner.data if is_image_empty(owner) else owner.image
def has_other_texture_image_user(node):
def has_other_image_user(owner):
"""Check actual Image references, excluding editor display and Fake User."""
tree = node.id_data
image = node.image
tree = None if is_image_empty(owner) else owner.id_data
image = owner_image(owner)
for user in bpy.data.user_map(subset={image})[image]:
if isinstance(user, bpy.types.Screen):
continue
if user == tree or getattr(user, "node_tree", None) == tree:
if user == owner:
continue
if tree is not None and (user == tree or getattr(user, "node_tree", None) == tree):
if any(
other != node and getattr(other, "image", None) == image
other != owner and getattr(other, "image", None) == image
for other in tree.nodes
):
return True
@@ -83,7 +86,7 @@ def replace_texture_image(node, result_image):
original_content = None
frame_count = video_total_frames(result_image) if is_animated_image(result_image) else None
try:
if has_other_texture_image_user(node):
if has_other_image_user(node):
final_image = result_image
else:
result_content = image_content_state(result_image)
@@ -121,6 +124,7 @@ class ImageEditTarget:
tree: object = None
start_frame: int = 1
frame_count: int = 1
node_identity: str = ""
@classmethod
def capture(cls, context):
@@ -129,10 +133,15 @@ class ImageEditTarget:
raise RuntimeError("Select an Image Empty or a material Image Texture")
image = owner_image(owner)
start_frame, frame_count = extract_frame_range(image, owner.image_user)
tree = None if is_image_empty(owner) else owner.id_data
identity = ""
if tree is not None:
identity = owner.get("anyimage_identity")
if not identity:
identity = uuid4().hex
owner["anyimage_identity"] = identity
return cls(
owner, image,
None if is_image_empty(owner) else owner.id_data,
start_frame, frame_count,
owner, image, tree, start_frame, frame_count, identity,
)
def validate(self):
@@ -146,6 +155,7 @@ class ImageEditTarget:
and self.image.is_editable
and self.tree.nodes.get(self.owner.name) == self.owner
and self.owner.image == self.image
and self.owner.get("anyimage_identity") == self.node_identity
)
if valid:
return
@@ -162,6 +172,19 @@ class ImageEditTarget:
def apply(self, directory, *, frame_prefix="image."):
self.validate()
result_image = load_image_edit_result(self.image, directory, frame_prefix=frame_prefix)
return self.commit(result_image)
def commit(self, result_image, *, isolate_shared=False):
"""Commit an owned, fully prepared image through the target transaction."""
try:
self.validate()
except Exception:
bpy.data.images.remove(result_image)
raise
if self.tree is None:
if isolate_shared:
return replace_empty_image(
self.owner, result_image, isolate_image=has_other_image_user(self.owner),
)
return replace_empty_image(self.owner, result_image)
return replace_texture_image(self.owner, result_image)

View File

@@ -6,10 +6,8 @@ from ..preferences import configured_material_view_adaptation
from .image import (
VIEW_TRANSFORM_COLOR_SPACES,
color_alpha_mode,
configure_image_user,
image_base_name,
image_pixels,
is_animated_image,
)
from .node import load_node_group
@@ -30,7 +28,9 @@ def material_color_space_name(scene):
def configure_material_color_image(image, scene=None):
"""Configure an owned material Color image using the current shading preference."""
"""Configure the selected Color image in place using the shading preference."""
if getattr(image, "is_float", False):
return image
if scene is None:
scene = getattr(bpy.context, "scene", None)
preferred = (
@@ -44,11 +44,9 @@ def configure_material_color_image(image, scene=None):
or getattr(image, "is_dirty", False)
else None
)
shared = image.users > int(image.use_fake_user)
result = image.copy() if shared else image
settings = result.colorspace_settings
settings = image.colorspace_settings
original_space = settings.name
original_alpha = result.alpha_mode
original_alpha = image.alpha_mode
try:
for color_space in dict.fromkeys((preferred, "sRGB")):
try:
@@ -59,21 +57,18 @@ def configure_material_color_image(image, scene=None):
break
else:
raise RuntimeError("Unable to configure the material color space")
result.alpha_mode = color_alpha_mode(settings.name)
image.alpha_mode = color_alpha_mode(settings.name)
if pixels is not None:
result.pixels.foreach_set(pixels)
result.update()
image.pixels.foreach_set(pixels)
image.update()
except Exception:
if shared:
bpy.data.images.remove(result)
else:
settings.name = original_space
result.alpha_mode = original_alpha
if pixels is not None:
result.pixels.foreach_set(pixels)
result.update()
settings.name = original_space
image.alpha_mode = original_alpha
if pixels is not None:
image.pixels.foreach_set(pixels)
image.update()
raise
return result
return image
def material_node_group(depth_plane=False):
@@ -93,178 +88,167 @@ def create_image_material(
displacement_midlevel=0.0,
displacement_scale=1.0,
shadeless=False,
frame_count=1,
frame_start=1,
color_frame_offset=0,
color_frame_duration=None,
depth_frame_offset=0,
depth_frame_duration=None,
use_cyclic=None,
normal_image=None,
normal_space="TANGENT",
scene=None,
texture_extension="EXTEND",
):
material_image = configure_material_color_image(color_image, scene)
preserve_color = color_image.is_float or (
shadeless and not color_image.colorspace_settings.name.endswith("sRGB")
)
material_image = color_image if preserve_color else configure_material_color_image(color_image, scene)
if material_image.source == "GENERATED" or material_image.is_dirty:
material_image.pack()
material = bpy.data.materials.new(image_base_name(source_image))
material.use_nodes = True
if hasattr(material, "surface_render_method"):
material.surface_render_method = "DITHERED"
nodes = material.node_tree.nodes
nodes.clear()
try:
material.use_nodes = True
if hasattr(material, "surface_render_method"):
material.surface_render_method = "DITHERED"
nodes = material.node_tree.nodes
nodes.clear()
color_texture = nodes.new("ShaderNodeTexImage")
uv_map = nodes.new("ShaderNodeUVMap")
output = nodes.new("ShaderNodeOutputMaterial")
color_texture = nodes.new("ShaderNodeTexImage")
uv_map = nodes.new("ShaderNodeUVMap")
output = nodes.new("ShaderNodeOutputMaterial")
uv_map.uv_map = "UVMap"
uv_map.location = (-1250.0, 350.0)
color_texture.image = material_image
color_texture.interpolation = "Linear"
color_texture.extension = "EXTEND"
color_texture.location = (-1000.0, 350.0)
output.location = (850.0, 350.0)
extracted_frames = frame_count if frame_count > 1 else None
color_frames = (
color_frame_duration if color_frame_duration is not None else extracted_frames
)
depth_frames = (
depth_frame_duration if depth_frame_duration is not None else extracted_frames
)
material_frames = color_frames
material_frame_offset = color_frame_offset
if frame_count > 1 or is_animated_image(material_image):
configure_image_user(
color_texture.image_user,
material_frames,
frame_start=frame_start,
frame_offset=material_frame_offset,
use_cyclic=use_cyclic,
)
links = material.node_tree.links
links.new(uv_map.outputs["UV"], color_texture.inputs["Vector"])
alpha = color_texture.outputs["Alpha"]
material_inputs = nodes.new("ShaderNodeGroup")
is_depth_plane = displacement_image is not None
material_inputs.node_tree = material_node_group(is_depth_plane)
material_inputs.location = (-300.0, 350.0)
material_inputs.inputs["Bump Scale"].default_value = depth_scale
if not is_depth_plane:
material_inputs.inputs["Alpha Fix"].default_value = float(
configured_material_view_adaptation()
)
if "Object Space" in material_inputs.inputs:
if normal_space not in {"TANGENT", "OBJECT"}:
raise ValueError(f"Unsupported normal space: {normal_space}")
material_inputs.inputs["Object Space"].default_value = normal_space == "OBJECT"
links.new(color_texture.outputs["Color"], material_inputs.inputs["Color"])
links.new(alpha, material_inputs.inputs["Alpha"])
if normal_image is not None and "Normal" in material_inputs.inputs:
normal_texture = nodes.new("ShaderNodeTexImage")
normal_texture.image = normal_image
normal_texture.interpolation = "Linear"
normal_texture.extension = "EXTEND"
normal_texture.location = (-1000.0, -50.0)
links.new(uv_map.outputs["UV"], normal_texture.inputs["Vector"])
links.new(normal_texture.outputs["Color"], material_inputs.inputs["Normal"])
if shadeless:
shader = nodes.new("ShaderNodeGroup")
shader.node_tree = load_node_group(SHADELESS_NODE_GROUP_NAME)
shader.name = SHADELESS_NODE_GROUP_NAME
transparent = nodes.new("ShaderNodeBsdfTransparent")
mix_shader = nodes.new("ShaderNodeMixShader")
shader.location = (0.0, 550.0)
transparent.location = (0.0, 150.0)
mix_shader.location = (300.0, 350.0)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Color"],
)
links.new(
material_inputs.outputs["Alpha"],
mix_shader.inputs["Fac"],
)
links.new(
transparent.outputs["BSDF"],
mix_shader.inputs[1],
)
links.new(
shader.outputs["Shader"],
mix_shader.inputs[2],
)
links.new(
mix_shader.outputs["Shader"],
output.inputs["Surface"],
)
else:
shader = nodes.new("ShaderNodeBsdfPrincipled")
shader.location = (250.0, 400.0)
shader.inputs["IOR"].default_value = 1.2
links.new(
material_inputs.outputs["Color"],
shader.inputs["Base Color"],
)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Subsurface Radius"],
)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Emission Color"],
)
links.new(material_inputs.outputs["Alpha"], shader.inputs["Alpha"])
links.new(material_inputs.outputs["Normal"], shader.inputs["Normal"])
links.new(
material_inputs.outputs["Normal"],
shader.inputs["Coat Normal"],
)
links.new(
material_inputs.outputs["Roughness"],
shader.inputs["Roughness"],
)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Specular IOR Level"],
)
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
if displacement_image is not None:
depth_texture = nodes.new("ShaderNodeTexImage")
depth_texture.image = displacement_image
depth_texture.interpolation = "Linear"
depth_texture.extension = "EXTEND"
if frame_count > 1 or is_animated_image(displacement_image):
configure_image_user(
depth_texture.image_user,
depth_frames,
frame_start=frame_start,
frame_offset=depth_frame_offset,
use_cyclic=use_cyclic,
uv_map.uv_map = "UVMap"
uv_map.location = (-1250.0, 350.0)
color_texture.image = material_image
color_texture.interpolation = "Linear"
color_texture.extension = texture_extension
color_texture.location = (-1000.0, 350.0)
output.location = (850.0, 350.0)
links = material.node_tree.links
links.new(uv_map.outputs["UV"], color_texture.inputs["Vector"])
if texture_extension == "REPEAT":
axes = nodes.new("ShaderNodeSeparateXYZ")
axes.location = (-1650.0, 350.0)
clamp = nodes.new("ShaderNodeClamp")
clamp.location = (-1450.0, 100.0)
half_pixel = 0.5 / max(1, int(material_image.size[1]))
clamp.inputs["Min"].default_value = half_pixel
clamp.inputs["Max"].default_value = 1.0 - half_pixel
coordinates = nodes.new("ShaderNodeCombineXYZ")
coordinates.location = (-1200.0, 350.0)
uv_map.location = (-1900.0, 350.0)
links.new(uv_map.outputs["UV"], axes.inputs[0])
links.new(axes.outputs["Y"], clamp.inputs["Value"])
links.new(axes.outputs["X"], coordinates.inputs["X"])
links.new(clamp.outputs[0], coordinates.inputs["Y"])
links.new(coordinates.outputs[0], color_texture.inputs["Vector"])
alpha = color_texture.outputs["Alpha"]
if shadeless and displacement_image is None:
shader = nodes.new("ShaderNodeGroup")
shader.node_tree = load_node_group(SHADELESS_NODE_GROUP_NAME)
shader.location = (0.0, 350.0)
links.new(color_texture.outputs["Color"], shader.inputs["Color"])
links.new(alpha, shader.inputs["Alpha"])
links.new(shader.outputs["Shader"], output.inputs["Surface"])
return material
material_inputs = nodes.new("ShaderNodeGroup")
is_depth_plane = displacement_image is not None
material_inputs.node_tree = material_node_group(is_depth_plane)
material_inputs.location = (-300.0, 350.0)
material_inputs.inputs["Bump Scale"].default_value = depth_scale
if not is_depth_plane:
material_inputs.inputs["Alpha Fix"].default_value = float(
not material_image.is_float and configured_material_view_adaptation()
)
if "Object Space" in material_inputs.inputs:
if normal_space not in {"TANGENT", "OBJECT"}:
raise ValueError(f"Unsupported normal space: {normal_space}")
material_inputs.inputs["Object Space"].default_value = normal_space == "OBJECT"
links.new(color_texture.outputs["Color"], material_inputs.inputs["Color"])
links.new(alpha, material_inputs.inputs["Alpha"])
if normal_image is not None and "Normal" in material_inputs.inputs:
normal_texture = nodes.new("ShaderNodeTexImage")
normal_texture.image = normal_image
normal_texture.interpolation = "Linear"
normal_texture.extension = "EXTEND"
normal_texture.location = (-1000.0, -50.0)
links.new(uv_map.outputs["UV"], normal_texture.inputs["Vector"])
links.new(normal_texture.outputs["Color"], material_inputs.inputs["Normal"])
if shadeless:
shader = nodes.new("ShaderNodeGroup")
shader.node_tree = load_node_group(SHADELESS_NODE_GROUP_NAME)
shader.name = SHADELESS_NODE_GROUP_NAME
shader.location = (0.0, 550.0)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Color"],
)
links.new(
material_inputs.outputs["Alpha"],
shader.inputs["Alpha"],
)
links.new(
shader.outputs["Shader"],
output.inputs["Surface"],
)
depth_texture.location = (-1000.0, -400.0)
links.new(uv_map.outputs["UV"], depth_texture.inputs["Vector"])
displacement_height = depth_texture.outputs["Color"]
if displacement_midlevel != 0.0 or displacement_scale != 1.0:
depth_adjust = nodes.new("ShaderNodeMath")
depth_adjust.operation = "SUBTRACT"
depth_adjust.inputs[1].default_value = displacement_midlevel
depth_adjust.location = (500.0, -550.0)
depth_scale_node = nodes.new("ShaderNodeMath")
depth_scale_node.operation = "MULTIPLY"
depth_scale_node.inputs[1].default_value = displacement_scale
depth_scale_node.location = (700.0, -550.0)
links.new(displacement_height, depth_adjust.inputs[0])
links.new(depth_adjust.outputs[0], depth_scale_node.inputs[0])
links.new(depth_scale_node.outputs[0], material_inputs.inputs["Depth"])
else:
links.new(displacement_height, material_inputs.inputs["Depth"])
links.new(
material_inputs.outputs["Displacement"],
output.inputs["Displacement"],
)
set_displacement_only(material)
return material
shader = nodes.new("ShaderNodeBsdfPrincipled")
shader.location = (250.0, 400.0)
shader.inputs["IOR"].default_value = 1.2
links.new(
material_inputs.outputs["Color"],
shader.inputs["Base Color"],
)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Subsurface Radius"],
)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Emission Color"],
)
links.new(material_inputs.outputs["Alpha"], shader.inputs["Alpha"])
links.new(material_inputs.outputs["Normal"], shader.inputs["Normal"])
links.new(
material_inputs.outputs["Normal"],
shader.inputs["Coat Normal"],
)
links.new(
material_inputs.outputs["Roughness"],
shader.inputs["Roughness"],
)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Specular IOR Level"],
)
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
if displacement_image is not None:
depth_texture = nodes.new("ShaderNodeTexImage")
depth_texture.image = displacement_image
depth_texture.interpolation = "Linear"
depth_texture.extension = "EXTEND"
depth_texture.location = (-1000.0, -400.0)
links.new(uv_map.outputs["UV"], depth_texture.inputs["Vector"])
displacement_height = depth_texture.outputs["Color"]
if displacement_midlevel != 0.0 or displacement_scale != 1.0:
depth_adjust = nodes.new("ShaderNodeMath")
depth_adjust.operation = "SUBTRACT"
depth_adjust.inputs[1].default_value = displacement_midlevel
depth_adjust.location = (500.0, -550.0)
depth_scale_node = nodes.new("ShaderNodeMath")
depth_scale_node.operation = "MULTIPLY"
depth_scale_node.inputs[1].default_value = displacement_scale
depth_scale_node.location = (700.0, -550.0)
links.new(displacement_height, depth_adjust.inputs[0])
links.new(depth_adjust.outputs[0], depth_scale_node.inputs[0])
links.new(depth_scale_node.outputs[0], material_inputs.inputs["Depth"])
else:
links.new(displacement_height, material_inputs.inputs["Depth"])
links.new(
material_inputs.outputs["Displacement"],
output.inputs["Displacement"],
)
set_displacement_only(material)
return material
except Exception:
bpy.data.materials.remove(material, do_unlink=True)
raise
def set_displacement_only(material):