Add tools and tests for AnyImage model management and documentation
- Implement sync_models.py for preparing and uploading ONNX artifacts. - Create build_node.py for building and validating Blender scripts. - Add docs.py for building and previewing documentation with Mike or MkDocs. - Introduce model_exports.py for exporting PyTorch models to ONNX format. - Develop pack.py for packaging the project with dependencies for different platforms. - Add test_sync_models.py to validate sync_models functionality. - Add test_tool_commands.py to ensure repository tools have correct command scripts. - Initialize tools package with __init__.py.
This commit is contained in:
1
src/anyimage/common/__init__.py
Normal file
1
src/anyimage/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared Blender-side image, node, object, interaction, and AI Job helpers."""
|
||||
78
src/anyimage/common/ai_job.py
Normal file
78
src/anyimage/common/ai_job.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from ..preferences import addon_preferences
|
||||
from ..properties import ai_ready, model_ready, model_record
|
||||
from ..runtime import runtime
|
||||
|
||||
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
BEN2_VIDEO_SUFFIXES = {
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".mts", ".mpg", ".mpeg"
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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 production_device():
|
||||
return getattr(addon_preferences(), "device", "AUTO")
|
||||
|
||||
|
||||
def require_environment():
|
||||
if not runtime.environment_ready():
|
||||
raise RuntimeError("Install the AnyImage Environment first")
|
||||
|
||||
|
||||
def invoke_ai_setup_if_needed():
|
||||
if ai_ready():
|
||||
return None
|
||||
return bpy.ops.anyimage.setup_ai_environment("INVOKE_DEFAULT")
|
||||
|
||||
|
||||
def require_model(model_key):
|
||||
model = model_record(model_key)
|
||||
if model is None:
|
||||
raise RuntimeError(f"Unknown model: {model_key}")
|
||||
if not model_ready(model_key):
|
||||
raise RuntimeError(f"Download {model['label']} first")
|
||||
return model
|
||||
|
||||
|
||||
def da3_parameters(settings, path, model, process_res, start_frame):
|
||||
return {
|
||||
"input": str(path),
|
||||
"model": model,
|
||||
"process_res": process_res,
|
||||
"process_res_method": "upper_bound_resize",
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": start_frame,
|
||||
"device": production_device().lower(),
|
||||
"ref_view_strategy": settings.ref_view_strategy,
|
||||
}
|
||||
|
||||
|
||||
def moge2_parameters(settings, path, model, resolution_level, start_frame):
|
||||
return {
|
||||
"input": str(path),
|
||||
"model": model,
|
||||
"resolution_level": int(resolution_level),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": start_frame,
|
||||
"device": production_device().lower(),
|
||||
}
|
||||
80
src/anyimage/common/depth_data.py
Normal file
80
src/anyimage/common/depth_data.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Load and scale Depth file artifacts shared by Blender workflows."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from .image_data import depth_data_name, image_empty_bounds
|
||||
|
||||
|
||||
def load_depth_metadata(path):
|
||||
"""Read and validate the compact camera metadata beside a Depth texture."""
|
||||
metadata = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
try:
|
||||
image_size = tuple(int(value) for value in metadata["image_size"])
|
||||
intrinsics = tuple(
|
||||
tuple(float(value) for value in row)
|
||||
for row in metadata["intrinsics"]
|
||||
)
|
||||
reference_depth = float(metadata["reference_depth"])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError("Depth metadata is invalid") from error
|
||||
if (
|
||||
len(image_size) != 2
|
||||
or min(image_size) <= 0
|
||||
or len(intrinsics) != 3
|
||||
or any(len(row) != 3 for row in intrinsics)
|
||||
or intrinsics[0][0] <= 0.0
|
||||
or intrinsics[1][1] <= 0.0
|
||||
or reference_depth <= 0.0
|
||||
):
|
||||
raise ValueError("Depth metadata is invalid")
|
||||
return {
|
||||
"image_size": image_size,
|
||||
"intrinsics": intrinsics,
|
||||
"reference_depth": reference_depth,
|
||||
}
|
||||
|
||||
|
||||
def load_depth_result_image(path, source_object, metadata):
|
||||
"""Load, validate, name, configure, and pack a generated Depth texture."""
|
||||
image = bpy.data.images.load(str(path), check_existing=False)
|
||||
try:
|
||||
if tuple(int(value) for value in image.size) != metadata["image_size"]:
|
||||
raise ValueError("Depth texture dimensions do not match its metadata")
|
||||
image.name = depth_data_name(source_object)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
image.pack()
|
||||
return image
|
||||
except Exception:
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
raise
|
||||
|
||||
|
||||
def camera_depth_values(image):
|
||||
"""Return top-down camera Z and validity from a loaded Depth texture."""
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image.size)
|
||||
pixels = np.empty(width * height * 4, dtype=np.float32)
|
||||
image.pixels.foreach_get(pixels)
|
||||
pixels = np.flipud(pixels.reshape((height, width, 4)))
|
||||
return pixels[..., 2], pixels[..., 3] > 0.5
|
||||
|
||||
|
||||
def depth_uniform_scale(source_object, metadata, bounds=None):
|
||||
"""Convert model camera units uniformly into Image Empty local distance."""
|
||||
import numpy as np
|
||||
|
||||
intrinsics = metadata["intrinsics"]
|
||||
width, height = metadata["image_size"]
|
||||
reference_depth = metadata["reference_depth"]
|
||||
x_min, x_max, y_min, y_max = bounds or image_empty_bounds(source_object)
|
||||
plane_width = x_max - x_min
|
||||
plane_height = y_max - y_min
|
||||
fx, fy = float(intrinsics[0][0]), float(intrinsics[1][1])
|
||||
scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth)
|
||||
scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth)
|
||||
return float(np.sqrt(scale_x * scale_y))
|
||||
570
src/anyimage/common/image_data.py
Normal file
570
src/anyimage/common/image_data.py
Normal file
@@ -0,0 +1,570 @@
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
IMAGE_NAME_SUFFIXES = (
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".png",
|
||||
".webp",
|
||||
".tiff",
|
||||
".tif",
|
||||
".exr",
|
||||
".hdr",
|
||||
".bmp",
|
||||
".tga",
|
||||
)
|
||||
|
||||
|
||||
VIDEO_NAME_SUFFIXES = (
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".webm",
|
||||
".m4v",
|
||||
".mts",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
)
|
||||
|
||||
|
||||
def is_image_empty(candidate):
|
||||
return bool(
|
||||
candidate
|
||||
and getattr(candidate, "type", None) == "EMPTY"
|
||||
and getattr(candidate, "empty_display_type", None) == "IMAGE"
|
||||
and getattr(candidate, "data", None) is not None
|
||||
)
|
||||
|
||||
|
||||
def require_image_empty(object_name):
|
||||
"""Return a named Image Empty or raise when it is no longer available."""
|
||||
source_object = bpy.data.objects.get(object_name)
|
||||
if not is_image_empty(source_object):
|
||||
raise RuntimeError("The source Image Empty is no longer available")
|
||||
return source_object
|
||||
|
||||
|
||||
def image_pixels(image):
|
||||
"""Read one Blender Image into a flat float32 pixel array."""
|
||||
import numpy as np
|
||||
|
||||
pixels = np.empty(len(image.pixels), dtype=np.float32)
|
||||
image.pixels.foreach_get(pixels)
|
||||
return pixels
|
||||
|
||||
|
||||
def image_rgba(image):
|
||||
"""Read one Blender Image into a top-down RGBA float32 array."""
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image.size)
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("The source image has no readable dimensions")
|
||||
return np.flipud(image_pixels(image).reshape((height, width, 4)))
|
||||
|
||||
|
||||
|
||||
def is_animated_image(image):
|
||||
return getattr(image, "source", None) in {"MOVIE", "SEQUENCE"}
|
||||
|
||||
|
||||
def video_total_frames(image):
|
||||
"""Return the total animated frame count, or 1 for a still image."""
|
||||
source = getattr(image, "source", None)
|
||||
if source == "MOVIE":
|
||||
return int(getattr(image, "frame_duration", 1) or 1)
|
||||
if source == "SEQUENCE":
|
||||
return len(collect_sequence_frames(image)) or 1
|
||||
return 1
|
||||
|
||||
|
||||
def image_user_settings(source_object):
|
||||
"""Return the Empty's image user settings as a four-item tuple."""
|
||||
image_user = getattr(source_object, "image_user", None)
|
||||
if image_user is None:
|
||||
return 1, 0, 0, False
|
||||
return (
|
||||
int(getattr(image_user, "frame_start", 1) or 1),
|
||||
int(getattr(image_user, "frame_offset", 0) or 0),
|
||||
int(getattr(image_user, "frame_duration", 0) or 0),
|
||||
bool(getattr(image_user, "use_cyclic", False)),
|
||||
)
|
||||
|
||||
|
||||
def extract_frame_range(source_object):
|
||||
"""Return the clamped (start_frame, frame_count) range extracted from the source."""
|
||||
total_frames = video_total_frames(getattr(source_object, "data", None))
|
||||
if total_frames <= 1:
|
||||
return 1, total_frames
|
||||
_frame_start, frame_offset, image_frames, _use_cyclic = (
|
||||
image_user_settings(source_object)
|
||||
)
|
||||
frame_count = (
|
||||
min(image_frames, total_frames)
|
||||
if image_frames >= 1
|
||||
else total_frames
|
||||
)
|
||||
start_frame = min(1 + frame_offset, total_frames - frame_count + 1)
|
||||
start_frame = max(start_frame, 1)
|
||||
return start_frame, frame_count
|
||||
|
||||
|
||||
def default_extract_frames(source_object):
|
||||
"""Return the Empty's Image Frames value, or the full frame count when unset."""
|
||||
return extract_frame_range(source_object)[1]
|
||||
|
||||
|
||||
def source_start_frame(source_object_name):
|
||||
source_object = bpy.data.objects.get(source_object_name)
|
||||
return extract_frame_range(source_object)[0] if source_object else 1
|
||||
|
||||
|
||||
def image_path(image):
|
||||
filepath_from_user = getattr(image, "filepath_from_user", None)
|
||||
filepath = (
|
||||
filepath_from_user()
|
||||
if callable(filepath_from_user)
|
||||
else getattr(image, "filepath", "")
|
||||
)
|
||||
if not filepath:
|
||||
return Path()
|
||||
return Path(bpy.path.abspath(filepath)).expanduser()
|
||||
|
||||
|
||||
def image_is_packed(image):
|
||||
if getattr(image, "packed_file", None) is not None:
|
||||
return True
|
||||
return bool(getattr(image, "packed_files", ()))
|
||||
|
||||
|
||||
def cleanup_image_input(path, temporary):
|
||||
"""Remove a prepared input and its temporary container when requested."""
|
||||
if not temporary or not path:
|
||||
return
|
||||
path = Path(path)
|
||||
try:
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
path.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def image_base_name(image):
|
||||
name = image.name
|
||||
lower_name = name.lower()
|
||||
for suffix in IMAGE_NAME_SUFFIXES + VIDEO_NAME_SUFFIXES:
|
||||
suffix_index = lower_name.rfind(suffix)
|
||||
if suffix_index <= 0:
|
||||
continue
|
||||
trailing = name[suffix_index + len(suffix) :]
|
||||
if trailing and not (
|
||||
trailing.startswith(".") and trailing[1:].isdigit()
|
||||
):
|
||||
continue
|
||||
return f"{name[:suffix_index]}{trailing}"
|
||||
return name
|
||||
|
||||
|
||||
def depth_data_name(source_object):
|
||||
return f"{image_base_name(source_object.data)}_depth.exr"
|
||||
|
||||
|
||||
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)
|
||||
raise RuntimeError("Unable to export the packed image") from error
|
||||
finally:
|
||||
image.file_format = original_file_format
|
||||
if not output_path.is_file():
|
||||
raise RuntimeError("Blender did not export the packed image")
|
||||
return output_path
|
||||
|
||||
|
||||
def collect_sequence_frames(image):
|
||||
"""Return the numbered frame files of a SEQUENCE image, or [] when absent."""
|
||||
source_path = image_path(image)
|
||||
if not is_animated_image(image) or not source_path.is_file():
|
||||
return []
|
||||
match = re.match(
|
||||
r"^(?P<prefix>.*?)(?P<digits>\d+)(?P<suffix>\.[^.]+)$",
|
||||
source_path.name,
|
||||
)
|
||||
if match is None:
|
||||
return []
|
||||
prefix = match.group("prefix")
|
||||
suffix = match.group("suffix").lower()
|
||||
frames = []
|
||||
for path in source_path.parent.iterdir():
|
||||
frame_match = re.match(
|
||||
r"^(?P<prefix>.*?)(?P<digits>\d+)(?P<suffix>\.[^.]+)$",
|
||||
path.name,
|
||||
)
|
||||
if (
|
||||
frame_match is not None
|
||||
and frame_match.group("prefix") == prefix
|
||||
and frame_match.group("suffix").lower() == suffix
|
||||
):
|
||||
frames.append((int(frame_match.group("digits")), path))
|
||||
return [path for _digits, path in sorted(frames)]
|
||||
|
||||
|
||||
def prepare_image_input(image, scene):
|
||||
source_path = image_path(image)
|
||||
if image_is_packed(image):
|
||||
return save_packed_image(image, scene), True
|
||||
if getattr(image, "source", None) == "MOVIE":
|
||||
if not source_path.is_file():
|
||||
raise RuntimeError(
|
||||
"The image empty must reference an existing video file"
|
||||
)
|
||||
return source_path, False
|
||||
sequence_frames = collect_sequence_frames(image)
|
||||
if sequence_frames:
|
||||
temporary_directory = Path(tempfile.mkdtemp(prefix="anyimage-input-"))
|
||||
try:
|
||||
for index, frame_path in enumerate(sequence_frames):
|
||||
suffix = frame_path.suffix.lower() or ".jpg"
|
||||
shutil.copy2(
|
||||
frame_path,
|
||||
temporary_directory / f"frame.{index + 1:04d}{suffix}",
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary_directory, ignore_errors=True)
|
||||
raise
|
||||
return temporary_directory, True
|
||||
if not source_path.is_file():
|
||||
raise RuntimeError(
|
||||
"The image empty must reference an existing image file"
|
||||
)
|
||||
return source_path, False
|
||||
|
||||
|
||||
def prepare_region_input(image, region_mask):
|
||||
"""Write one cropped RGBA Region input owned by its temporary directory."""
|
||||
import numpy as np
|
||||
|
||||
left, top, right, bottom = region_mask.bounds
|
||||
rgba = image_rgba(image)
|
||||
cropped = rgba[top:bottom, left:right].copy()
|
||||
if cropped.shape[:2] != region_mask.values.shape:
|
||||
raise ValueError("The Region does not match the source image")
|
||||
cropped[:, :, 3] *= region_mask.values
|
||||
cropped[cropped[:, :, 3] <= 0.0, :3] = 0.0
|
||||
output_directory = Path(tempfile.mkdtemp(prefix="anyimage-region-"))
|
||||
output_path = output_directory / "input.png"
|
||||
temporary_image = bpy.data.images.new(
|
||||
"AnyImage Region 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 (OSError, RuntimeError):
|
||||
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 Region input")
|
||||
return output_path
|
||||
|
||||
|
||||
def configure_image_user(
|
||||
image_user,
|
||||
frame_count=None,
|
||||
frame_start=1,
|
||||
frame_offset=0,
|
||||
use_cyclic=None,
|
||||
):
|
||||
"""Map the image to scene frames and enable auto refresh."""
|
||||
if image_user is None:
|
||||
return
|
||||
if hasattr(image_user, "frame_start"):
|
||||
image_user.frame_start = frame_start
|
||||
if hasattr(image_user, "frame_offset"):
|
||||
image_user.frame_offset = frame_offset
|
||||
if hasattr(image_user, "use_auto_refresh"):
|
||||
image_user.use_auto_refresh = True
|
||||
if use_cyclic is not None and hasattr(image_user, "use_cyclic"):
|
||||
image_user.use_cyclic = use_cyclic
|
||||
if frame_count is not None and hasattr(image_user, "frame_duration"):
|
||||
image_user.frame_duration = frame_count
|
||||
|
||||
|
||||
def pack_sequence_if_possible(image, expected_frames):
|
||||
"""Pack a frame sequence when Blender supports it; otherwise keep external files."""
|
||||
if expected_frames <= 1:
|
||||
return False
|
||||
try:
|
||||
image.pack()
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
if len(getattr(image, "packed_files", ())) >= expected_frames:
|
||||
return True
|
||||
try:
|
||||
image.unpack(method="REMOVE")
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def sorted_frame_paths(directory, prefix=None):
|
||||
"""Return image files in a directory sorted by their trailing frame number."""
|
||||
def frame_key(path):
|
||||
match = re.search(r"(\d+)(?=\.[^.]+$)", path.name)
|
||||
return (int(match.group(1)) if match is not None else 0, path.name)
|
||||
|
||||
return sorted(
|
||||
(
|
||||
path
|
||||
for path in Path(directory).iterdir()
|
||||
if path.is_file()
|
||||
and path.suffix.lower() in IMAGE_NAME_SUFFIXES
|
||||
and (prefix is None or path.stem.startswith(prefix))
|
||||
),
|
||||
key=frame_key,
|
||||
)
|
||||
|
||||
|
||||
def force_sequence_source(image):
|
||||
"""Ensure a loaded image is a SEQUENCE datablock for its numbered frames."""
|
||||
if getattr(image, "source", None) == "SEQUENCE":
|
||||
return image
|
||||
try:
|
||||
image.source = "SEQUENCE"
|
||||
except (OSError, RuntimeError, TypeError) as error:
|
||||
raise RuntimeError(
|
||||
"Blender cannot load the frame sequence"
|
||||
) from error
|
||||
if getattr(image, "source", None) != "SEQUENCE":
|
||||
raise RuntimeError("Blender cannot load the frame sequence")
|
||||
return image
|
||||
|
||||
|
||||
def load_normal_result_image(path, source_object):
|
||||
"""Load, name, configure, and pack a generated normal image."""
|
||||
image = bpy.data.images.load(str(path), check_existing=False)
|
||||
image.name = normal_data_name(source_object)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.pack()
|
||||
return image
|
||||
|
||||
|
||||
def create_pixel_result_image(source_image, pixels, output_size, suffix=""):
|
||||
"""Create a packed Image datablock from edited RGBA pixels."""
|
||||
output_width, output_height = (int(value) for value in output_size)
|
||||
if output_width <= 0 or output_height <= 0:
|
||||
raise ValueError("The edited image dimensions must be positive")
|
||||
if len(pixels) != output_width * output_height * 4:
|
||||
raise ValueError("The edited pixels do not match the image dimensions")
|
||||
image = bpy.data.images.new(
|
||||
f"{image_base_name(source_image)}{suffix}",
|
||||
width=output_width,
|
||||
height=output_height,
|
||||
alpha=True,
|
||||
)
|
||||
try:
|
||||
image.colorspace_settings.name = source_image.colorspace_settings.name
|
||||
image.alpha_mode = "STRAIGHT"
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.pack()
|
||||
image.update()
|
||||
except Exception:
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
raise
|
||||
return image
|
||||
|
||||
|
||||
def replace_empty_image(source_object, result_image, *, placement_bounds=None):
|
||||
"""Replace one Empty image, retaining shared source images."""
|
||||
source_image = source_object.data
|
||||
source_name = source_image.name
|
||||
original_fake_user = source_image.use_fake_user
|
||||
image_user = getattr(source_object, "image_user", None)
|
||||
frame_count = (
|
||||
video_total_frames(result_image)
|
||||
if is_animated_image(result_image)
|
||||
else None
|
||||
)
|
||||
alignment = (
|
||||
empty_display_alignment(
|
||||
source_object,
|
||||
tuple(source_image.size),
|
||||
tuple(result_image.size),
|
||||
placement_bounds,
|
||||
)
|
||||
if placement_bounds is not None
|
||||
else None
|
||||
)
|
||||
if frame_count is not None:
|
||||
(
|
||||
original_frame_start,
|
||||
_original_frame_offset,
|
||||
_original_frame_duration,
|
||||
original_use_cyclic,
|
||||
) = image_user_settings(source_object)
|
||||
|
||||
source_image.use_fake_user = True
|
||||
try:
|
||||
source_object.data = result_image
|
||||
if frame_count is not None:
|
||||
configure_image_user(
|
||||
image_user,
|
||||
frame_count,
|
||||
frame_start=original_frame_start,
|
||||
use_cyclic=original_use_cyclic,
|
||||
)
|
||||
if alignment is not None:
|
||||
source_object.empty_display_size = alignment[0]
|
||||
source_object.empty_image_offset = alignment[1]
|
||||
except Exception:
|
||||
source_object.data = source_image
|
||||
source_image.use_fake_user = original_fake_user
|
||||
raise
|
||||
|
||||
source_image.use_fake_user = original_fake_user
|
||||
if not original_fake_user and int(source_image.users) == 0:
|
||||
bpy.data.images.remove(source_image, do_unlink=True)
|
||||
result_image.name = source_name
|
||||
return result_image
|
||||
|
||||
|
||||
|
||||
def load_color_result_image(
|
||||
source_object,
|
||||
output_directory,
|
||||
*,
|
||||
frame_prefix="image.",
|
||||
):
|
||||
output_directory = Path(output_directory)
|
||||
color_paths = sorted_frame_paths(output_directory, frame_prefix)
|
||||
if not color_paths:
|
||||
raise RuntimeError(
|
||||
f"Color result has no images: {output_directory}"
|
||||
)
|
||||
source_image = source_object.data
|
||||
source_color_space = source_image.colorspace_settings.name
|
||||
animated = is_animated_image(source_image)
|
||||
try:
|
||||
result_image = bpy.data.images.load(
|
||||
str(color_paths[0]),
|
||||
check_existing=False,
|
||||
)
|
||||
result_image.alpha_mode = "CHANNEL_PACKED"
|
||||
result_image.colorspace_settings.name = source_color_space
|
||||
if animated:
|
||||
force_sequence_source(result_image)
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as error:
|
||||
if "result_image" in locals():
|
||||
bpy.data.images.remove(result_image, do_unlink=True)
|
||||
raise RuntimeError("Unable to load the color result") from error
|
||||
|
||||
try:
|
||||
result_name = color_data_name(source_object)
|
||||
if animated:
|
||||
pack_sequence_if_possible(result_image, len(color_paths))
|
||||
else:
|
||||
result_image.pack()
|
||||
result_image.name = result_name
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as error:
|
||||
bpy.data.images.remove(result_image, do_unlink=True)
|
||||
raise RuntimeError("Unable to prepare the color result") from error
|
||||
return result_image
|
||||
|
||||
|
||||
def image_empty_bounds(source_object):
|
||||
image_width, image_height = source_object.data.size
|
||||
if image_width <= 0 or image_height <= 0:
|
||||
raise ValueError("The source image has no readable dimensions")
|
||||
|
||||
aspect = image_width / image_height
|
||||
display_size = float(source_object.empty_display_size)
|
||||
if aspect >= 1.0:
|
||||
width = display_size
|
||||
height = display_size / aspect
|
||||
else:
|
||||
width = display_size * aspect
|
||||
height = display_size
|
||||
|
||||
offset = source_object.empty_image_offset
|
||||
x_min = float(offset[0]) * width
|
||||
y_min = float(offset[1]) * height
|
||||
return (
|
||||
x_min,
|
||||
x_min + width,
|
||||
y_min,
|
||||
y_min + height,
|
||||
)
|
||||
|
||||
|
||||
def empty_display_alignment(
|
||||
source_object,
|
||||
source_size,
|
||||
output_size,
|
||||
placement_bounds,
|
||||
):
|
||||
"""Return the display size and offset for an edited Image Empty."""
|
||||
source_width, source_height = source_size
|
||||
output_width, output_height = output_size
|
||||
left, top, right, bottom = placement_bounds
|
||||
if (
|
||||
source_width <= 0
|
||||
or source_height <= 0
|
||||
or output_width <= 0
|
||||
or output_height <= 0
|
||||
or right <= left
|
||||
or bottom <= top
|
||||
):
|
||||
raise ValueError("The edited image placement is invalid")
|
||||
x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
|
||||
local_width = x_max - x_min
|
||||
local_height = y_max - y_min
|
||||
desired_x_min = x_min + left * local_width / source_width
|
||||
desired_x_max = x_min + right * local_width / source_width
|
||||
desired_y_max = y_max - top * local_height / source_height
|
||||
desired_y_min = y_max - bottom * local_height / source_height
|
||||
desired_width = desired_x_max - desired_x_min
|
||||
desired_height = desired_y_max - desired_y_min
|
||||
display_size = max(desired_width, desired_height)
|
||||
aspect = output_width / output_height
|
||||
if aspect >= 1.0:
|
||||
display_width = display_size
|
||||
display_height = display_size / aspect
|
||||
else:
|
||||
display_width = display_size * aspect
|
||||
display_height = display_size
|
||||
center_x = (desired_x_min + desired_x_max) * 0.5
|
||||
center_y = (desired_y_min + desired_y_max) * 0.5
|
||||
return display_size, (
|
||||
center_x / display_width - 0.5,
|
||||
center_y / display_height - 0.5,
|
||||
)
|
||||
893
src/anyimage/common/image_interaction.py
Normal file
893
src/anyimage/common/image_interaction.py
Normal file
@@ -0,0 +1,893 @@
|
||||
import json
|
||||
import math
|
||||
|
||||
import bpy
|
||||
|
||||
from ..properties import ai_ready, ai_setup_label, ai_status
|
||||
from .image_data import (
|
||||
image_empty_bounds,
|
||||
is_image_empty,
|
||||
require_image_empty,
|
||||
video_total_frames,
|
||||
)
|
||||
from .image_region import ImageEditWarning, RegionPath
|
||||
|
||||
MIN_PATH_DISTANCE = 2.0
|
||||
|
||||
|
||||
def simplify_screen_path(path):
|
||||
pixels = []
|
||||
for point in path:
|
||||
point = (float(point[0]), float(point[1]))
|
||||
if not pixels or math.dist(point, pixels[-1]) >= MIN_PATH_DISTANCE:
|
||||
pixels.append(point)
|
||||
return pixels
|
||||
|
||||
|
||||
def screen_path_to_image_pixels(
|
||||
path,
|
||||
view_region,
|
||||
view3d,
|
||||
matrix_world,
|
||||
local_bounds,
|
||||
image_size,
|
||||
):
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
inverse = matrix_world.inverted()
|
||||
x_min, x_max, y_min, y_max = local_bounds
|
||||
image_width, image_height = image_size
|
||||
local_width = x_max - x_min
|
||||
local_height = y_max - y_min
|
||||
pixels = []
|
||||
for point in simplify_screen_path(path):
|
||||
ray_origin = inverse @ view3d_utils.region_2d_to_origin_3d(
|
||||
view_region,
|
||||
view3d,
|
||||
point,
|
||||
)
|
||||
ray_direction = inverse.to_3x3() @ view3d_utils.region_2d_to_vector_3d(
|
||||
view_region,
|
||||
view3d,
|
||||
point,
|
||||
)
|
||||
if abs(ray_direction.z) <= 1e-10:
|
||||
raise ValueError("The Image Empty is edge-on to the current view")
|
||||
distance = -ray_origin.z / ray_direction.z
|
||||
local = ray_origin + ray_direction * distance
|
||||
pixels.append(
|
||||
(
|
||||
(local.x - x_min) * image_width / local_width,
|
||||
(y_max - local.y) * image_height / local_height,
|
||||
)
|
||||
)
|
||||
return pixels
|
||||
|
||||
|
||||
def serialize_matrix(matrix):
|
||||
return json.dumps(
|
||||
[float(matrix[row][column]) for row in range(4) for column in range(4)],
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def deserialize_matrix(data):
|
||||
from mathutils import Matrix
|
||||
|
||||
values = json.loads(data)
|
||||
if len(values) != 16:
|
||||
raise ValueError("The stored Image Empty transform is invalid")
|
||||
return Matrix(tuple(tuple(values[row * 4 : row * 4 + 4]) for row in range(4)))
|
||||
|
||||
|
||||
def drawing_in_region(area_pointer, region_pointer):
|
||||
area = getattr(bpy.context, "area", None)
|
||||
region = getattr(bpy.context, "region", None)
|
||||
return (
|
||||
area is not None
|
||||
and region is not None
|
||||
and area.as_pointer() == area_pointer
|
||||
and region.as_pointer() == region_pointer
|
||||
)
|
||||
|
||||
|
||||
def report_image_edit_exception(operator, error):
|
||||
level = "WARNING" if isinstance(error, ImageEditWarning) else "ERROR"
|
||||
operator.report({level}, str(error))
|
||||
|
||||
|
||||
def image_edit_poll(context):
|
||||
return context.area is not None and context.area.type == "VIEW_3D"
|
||||
|
||||
|
||||
def _restore_selection(context, active_object, selected_objects):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for selected in selected_objects:
|
||||
if bpy.data.objects.get(selected.name) is not None:
|
||||
selected.select_set(True)
|
||||
if (
|
||||
active_object is not None
|
||||
and bpy.data.objects.get(active_object.name) is not None
|
||||
):
|
||||
context.view_layer.objects.active = active_object
|
||||
|
||||
|
||||
def resolve_image_edit_click(context, event, report):
|
||||
"""Resolve the first click into source selection or image editing."""
|
||||
active_object = context.active_object
|
||||
selected_objects = tuple(context.selected_objects)
|
||||
try:
|
||||
bpy.ops.view3d.select(
|
||||
location=(event.mouse_region_x, event.mouse_region_y),
|
||||
extend=False,
|
||||
deselect_all=False,
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
clicked_object = context.active_object
|
||||
if is_image_empty(clicked_object) and clicked_object != active_object:
|
||||
return clicked_object, False
|
||||
if is_image_empty(clicked_object):
|
||||
return clicked_object, True
|
||||
if is_image_empty(active_object):
|
||||
_restore_selection(context, active_object, selected_objects)
|
||||
return active_object, True
|
||||
report({"WARNING"}, "Click an Image Empty to use an Image tool")
|
||||
return None, False
|
||||
|
||||
|
||||
def active_view3d_tool_id(context):
|
||||
tools = getattr(getattr(context, "workspace", None), "tools", None)
|
||||
from_mode = getattr(tools, "from_space_view3d_mode", None)
|
||||
if not callable(from_mode):
|
||||
return None
|
||||
try:
|
||||
tool = from_mode(getattr(context, "mode", "OBJECT"), create=False)
|
||||
except (RuntimeError, TypeError):
|
||||
return None
|
||||
return getattr(tool, "idname", None)
|
||||
|
||||
|
||||
def refine_region_setting_name(operator):
|
||||
setting_names = getattr(operator, "refine_region_settings", {})
|
||||
return setting_names.get(operator.gesture)
|
||||
|
||||
|
||||
def apply_region_settings(operator, context):
|
||||
settings = context.scene.anyimage_settings
|
||||
setting_name = refine_region_setting_name(operator)
|
||||
operator.refine_region = (
|
||||
setting_name is not None and ai_ready() and getattr(settings, setting_name)
|
||||
)
|
||||
|
||||
|
||||
def draw_region_operator_settings(layout, operator):
|
||||
if operator.gesture not in {"BOX", "LASSO"}:
|
||||
layout.prop(operator, "invert")
|
||||
return
|
||||
status = ai_status()
|
||||
row = layout.row()
|
||||
row.enabled = status["ready"]
|
||||
row.prop(operator, "refine_region")
|
||||
layout.prop(operator, "invert")
|
||||
if not status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
|
||||
|
||||
def draw_region_settings(context, layout, setting_name):
|
||||
settings = context.scene.anyimage_settings
|
||||
status = ai_status()
|
||||
row = layout.row()
|
||||
row.enabled = status["ready"]
|
||||
row.prop(settings, setting_name)
|
||||
if not status["ready"]:
|
||||
layout.operator(
|
||||
"anyimage.setup_ai_environment",
|
||||
text=ai_setup_label(status),
|
||||
icon="IMPORT",
|
||||
)
|
||||
|
||||
|
||||
def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
|
||||
if dash_length <= 0.0 or gap_length < 0.0:
|
||||
raise ValueError(
|
||||
"Dash length must be positive and gap length cannot be negative"
|
||||
)
|
||||
pattern_length = dash_length + gap_length
|
||||
segments = []
|
||||
distance_offset = 0.0
|
||||
for start, end in zip(vertices, vertices[1:]):
|
||||
start_x, start_y = (float(value) for value in start)
|
||||
end_x, end_y = (float(value) for value in end)
|
||||
segment_length = math.hypot(end_x - start_x, end_y - start_y)
|
||||
if segment_length <= 0.0:
|
||||
continue
|
||||
direction_x = (end_x - start_x) / segment_length
|
||||
direction_y = (end_y - start_y) / segment_length
|
||||
position = 0.0
|
||||
while position < segment_length:
|
||||
phase = (distance_offset + position) % pattern_length
|
||||
if phase < dash_length:
|
||||
run_length = min(dash_length - phase, segment_length - position)
|
||||
segments.extend(
|
||||
(
|
||||
(
|
||||
start_x + direction_x * position,
|
||||
start_y + direction_y * position,
|
||||
),
|
||||
(
|
||||
start_x + direction_x * (position + run_length),
|
||||
start_y + direction_y * (position + run_length),
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
run_length = min(pattern_length - phase, segment_length - position)
|
||||
position += run_length
|
||||
distance_offset += segment_length
|
||||
return tuple(segments)
|
||||
|
||||
|
||||
def draw_dashed_line(
|
||||
vertices,
|
||||
color=(1.0, 1.0, 1.0, 0.9),
|
||||
segments=None,
|
||||
):
|
||||
import gpu
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
|
||||
if segments is None:
|
||||
segments = dashed_line_segments(vertices)
|
||||
if not segments:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
batch = batch_for_shader(shader, "LINES", {"pos": segments})
|
||||
gpu.state.blend_set("ALPHA")
|
||||
gpu.state.line_width_set(1.0)
|
||||
shader.bind()
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
def _signed_area(vertices):
|
||||
return (
|
||||
sum(
|
||||
start[0] * end[1] - end[0] * start[1]
|
||||
for start, end in zip(vertices, (*vertices[1:], vertices[0]))
|
||||
)
|
||||
* 0.5
|
||||
)
|
||||
|
||||
|
||||
def _triangle_cross(start, middle, end):
|
||||
return (middle[0] - start[0]) * (end[1] - start[1]) - (middle[1] - start[1]) * (
|
||||
end[0] - start[0]
|
||||
)
|
||||
|
||||
|
||||
def _point_in_triangle(point, start, middle, end, orientation, epsilon):
|
||||
return all(
|
||||
orientation * cross >= -epsilon
|
||||
for cross in (
|
||||
_triangle_cross(start, middle, point),
|
||||
_triangle_cross(middle, end, point),
|
||||
_triangle_cross(end, start, point),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _triangulated_polygon_vertices_python(vertices):
|
||||
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
|
||||
if len(vertices) < 3:
|
||||
return ()
|
||||
area = _signed_area(vertices)
|
||||
if abs(area) <= 1e-8:
|
||||
return ()
|
||||
orientation = 1.0 if area > 0.0 else -1.0
|
||||
remaining = list(range(len(vertices)))
|
||||
triangles = []
|
||||
epsilon = 1e-8
|
||||
while len(remaining) > 3:
|
||||
for position, current in enumerate(remaining):
|
||||
previous = remaining[position - 1]
|
||||
following = remaining[(position + 1) % len(remaining)]
|
||||
start = vertices[previous]
|
||||
middle = vertices[current]
|
||||
end = vertices[following]
|
||||
if orientation * _triangle_cross(start, middle, end) <= epsilon:
|
||||
continue
|
||||
if any(
|
||||
_point_in_triangle(
|
||||
vertices[index],
|
||||
start,
|
||||
middle,
|
||||
end,
|
||||
orientation,
|
||||
epsilon,
|
||||
)
|
||||
for index in remaining
|
||||
if index not in {previous, current, following}
|
||||
):
|
||||
continue
|
||||
triangles.extend((start, middle, end))
|
||||
del remaining[position]
|
||||
break
|
||||
else:
|
||||
return ()
|
||||
triangles.extend(vertices[index] for index in remaining)
|
||||
return tuple(triangles)
|
||||
|
||||
|
||||
def triangulated_polygon_vertices(vertices):
|
||||
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
|
||||
try:
|
||||
import mathutils
|
||||
|
||||
points = [mathutils.Vector((vertex[0], vertex[1], 0.0)) for vertex in vertices]
|
||||
triangles = mathutils.geometry.tessellate_polygon([points])
|
||||
return tuple(
|
||||
(float(point[0]), float(point[1]))
|
||||
for triangle in triangles
|
||||
for point in triangle
|
||||
)
|
||||
except (AttributeError, ImportError, RuntimeError, TypeError, ValueError):
|
||||
return _triangulated_polygon_vertices_python(vertices)
|
||||
|
||||
|
||||
def scanline_fill_triangles(vertices, fill_rule="EVEN_ODD", step=2.0):
|
||||
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
|
||||
if len(vertices) < 3:
|
||||
return ()
|
||||
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
|
||||
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
|
||||
minimum_y = min(vertex[1] for vertex in vertices)
|
||||
maximum_y = max(vertex[1] for vertex in vertices)
|
||||
band_bottom = minimum_y
|
||||
triangles = []
|
||||
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
|
||||
while band_bottom < maximum_y:
|
||||
band_top = min(band_bottom + step, maximum_y)
|
||||
sample_y = (band_bottom + band_top) * 0.5
|
||||
events = []
|
||||
for start, end in edges:
|
||||
if (start[1] <= sample_y < end[1]) or (end[1] <= sample_y < start[1]):
|
||||
factor = (sample_y - start[1]) / (end[1] - start[1])
|
||||
events.append(
|
||||
(
|
||||
start[0] + (end[0] - start[0]) * factor,
|
||||
1 if end[1] > start[1] else -1,
|
||||
)
|
||||
)
|
||||
events.sort(key=lambda event: event[0])
|
||||
intervals = []
|
||||
winding = 0
|
||||
interval_start = None
|
||||
index = 0
|
||||
while index < len(events):
|
||||
x = events[index][0]
|
||||
delta = 0
|
||||
count = 0
|
||||
while index < len(events) and abs(events[index][0] - x) <= 1e-8:
|
||||
delta += events[index][1]
|
||||
count += 1
|
||||
index += 1
|
||||
was_inside = winding != 0
|
||||
if fill_rule == "EVEN_ODD":
|
||||
if count % 2:
|
||||
winding = 0 if was_inside else 1
|
||||
else:
|
||||
winding += delta
|
||||
is_inside = winding != 0
|
||||
if not was_inside and is_inside:
|
||||
interval_start = x
|
||||
elif was_inside and not is_inside and interval_start is not None:
|
||||
intervals.append((interval_start, x))
|
||||
interval_start = None
|
||||
for left, right in intervals:
|
||||
if right - left <= 1e-8:
|
||||
continue
|
||||
triangles.extend(
|
||||
(
|
||||
(left, band_bottom),
|
||||
(right, band_bottom),
|
||||
(right, band_top),
|
||||
(left, band_bottom),
|
||||
(right, band_top),
|
||||
(left, band_top),
|
||||
)
|
||||
)
|
||||
band_bottom = band_top
|
||||
return tuple(triangles)
|
||||
|
||||
|
||||
def _point_on_segment(point, start, end, epsilon=1e-8):
|
||||
return (
|
||||
min(start[0], end[0]) - epsilon <= point[0] <= max(start[0], end[0]) + epsilon
|
||||
and min(start[1], end[1]) - epsilon
|
||||
<= point[1]
|
||||
<= max(start[1], end[1]) + epsilon
|
||||
)
|
||||
|
||||
|
||||
def _segments_intersect(first_start, first_end, second_start, second_end):
|
||||
crosses = (
|
||||
_triangle_cross(first_start, first_end, second_start),
|
||||
_triangle_cross(first_start, first_end, second_end),
|
||||
_triangle_cross(second_start, second_end, first_start),
|
||||
_triangle_cross(second_start, second_end, first_end),
|
||||
)
|
||||
if (crosses[0] > 1e-8) != (crosses[1] > 1e-8) and (crosses[2] > 1e-8) != (
|
||||
crosses[3] > 1e-8
|
||||
):
|
||||
return True
|
||||
return any(
|
||||
abs(cross) <= 1e-8 and _point_on_segment(point, start, end)
|
||||
for cross, point, start, end in (
|
||||
(crosses[0], second_start, first_start, first_end),
|
||||
(crosses[1], second_end, first_start, first_end),
|
||||
(crosses[2], first_start, second_start, second_end),
|
||||
(crosses[3], first_end, second_start, second_end),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def polygon_self_intersects(vertices, cell_size=32.0):
|
||||
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
|
||||
if len(vertices) < 4:
|
||||
return False
|
||||
edges = tuple(zip(vertices, (*vertices[1:], vertices[0])))
|
||||
cells = {}
|
||||
last_edge = len(edges) - 1
|
||||
for edge_index, (start, end) in enumerate(edges):
|
||||
left = math.floor(min(start[0], end[0]) / cell_size)
|
||||
right = math.floor(max(start[0], end[0]) / cell_size)
|
||||
bottom = math.floor(min(start[1], end[1]) / cell_size)
|
||||
top = math.floor(max(start[1], end[1]) / cell_size)
|
||||
edge_cells = tuple(
|
||||
(x, y) for x in range(left, right + 1) for y in range(bottom, top + 1)
|
||||
)
|
||||
candidates = {
|
||||
candidate for cell in edge_cells for candidate in cells.get(cell, ())
|
||||
}
|
||||
for candidate in candidates:
|
||||
if abs(edge_index - candidate) <= 1 or {
|
||||
edge_index,
|
||||
candidate,
|
||||
} == {0, last_edge}:
|
||||
continue
|
||||
if _segments_intersect(start, end, *edges[candidate]):
|
||||
return True
|
||||
for cell in edge_cells:
|
||||
cells.setdefault(cell, []).append(edge_index)
|
||||
return False
|
||||
|
||||
|
||||
def preview_fill_triangles(vertices, fill_rule="EVEN_ODD"):
|
||||
if not polygon_self_intersects(vertices):
|
||||
triangles = triangulated_polygon_vertices(vertices)
|
||||
if triangles:
|
||||
return triangles
|
||||
return scanline_fill_triangles(vertices, fill_rule)
|
||||
|
||||
|
||||
def draw_polygon_fill(
|
||||
vertices,
|
||||
triangles=None,
|
||||
color=(1.0, 1.0, 1.0, 0.16),
|
||||
):
|
||||
import gpu
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
|
||||
if triangles is None:
|
||||
triangles = triangulated_polygon_vertices(vertices)
|
||||
if not triangles:
|
||||
return
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
batch = batch_for_shader(shader, "TRIS", {"pos": triangles})
|
||||
gpu.state.blend_set("ALPHA")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
GESTURE_TOOL_IDS = {
|
||||
"BOX": "anyimage.image_box",
|
||||
"LASSO": "anyimage.image_lasso",
|
||||
"LINE": "anyimage.image_line",
|
||||
"POLYLINE": "anyimage.image_polyline",
|
||||
}
|
||||
LINE_SNAP_RADIANS = math.radians(5.0)
|
||||
POLYLINE_CLOSE_RADIUS = 8.0
|
||||
LASSO_POINT_DISTANCE = 2.0
|
||||
LASSO_SIMPLIFY_TOLERANCE = 0.75
|
||||
|
||||
|
||||
def append_lasso_point(path, point):
|
||||
point = (float(point[0]), float(point[1]))
|
||||
if not path:
|
||||
path.append(point)
|
||||
return True
|
||||
if math.dist(point, path[-1]) < LASSO_POINT_DISTANCE:
|
||||
return False
|
||||
if len(path) >= 2:
|
||||
anchor = path[-2]
|
||||
previous = path[-1]
|
||||
segment_x = point[0] - anchor[0]
|
||||
segment_y = point[1] - anchor[1]
|
||||
segment_length_squared = segment_x * segment_x + segment_y * segment_y
|
||||
if segment_length_squared > 0.0:
|
||||
factor = (
|
||||
(previous[0] - anchor[0]) * segment_x
|
||||
+ (previous[1] - anchor[1]) * segment_y
|
||||
) / segment_length_squared
|
||||
projected = (
|
||||
anchor[0] + segment_x * factor,
|
||||
anchor[1] + segment_y * factor,
|
||||
)
|
||||
forward = (previous[0] - anchor[0]) * (point[0] - previous[0]) + (
|
||||
previous[1] - anchor[1]
|
||||
) * (point[1] - previous[1])
|
||||
if (
|
||||
0.0 <= factor <= 1.0
|
||||
and forward >= 0.0
|
||||
and math.dist(previous, projected) <= LASSO_SIMPLIFY_TOLERANCE
|
||||
):
|
||||
path[-1] = point
|
||||
return True
|
||||
path.append(point)
|
||||
return True
|
||||
|
||||
|
||||
def gesture_property():
|
||||
return bpy.props.EnumProperty(
|
||||
items=(
|
||||
("BOX", "Box", "Edit with a rectangular gesture"),
|
||||
("LASSO", "Lasso", "Edit with a freehand gesture"),
|
||||
("LINE", "Line", "Edit one side of a line"),
|
||||
("POLYLINE", "Polyline", "Edit with a clicked polygon"),
|
||||
),
|
||||
default="BOX",
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
|
||||
|
||||
def box_screen_path(start, end):
|
||||
x0, y0 = start
|
||||
x1, y1 = end
|
||||
if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0:
|
||||
return ()
|
||||
return ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
|
||||
|
||||
|
||||
def snapped_line_endpoint(start, end, snap):
|
||||
if not snap:
|
||||
return tuple(float(value) for value in end)
|
||||
delta_x = float(end[0]) - float(start[0])
|
||||
delta_y = float(end[1]) - float(start[1])
|
||||
length = math.hypot(delta_x, delta_y)
|
||||
if length <= 0.0:
|
||||
return tuple(float(value) for value in end)
|
||||
angle = round(math.atan2(delta_y, delta_x) / LINE_SNAP_RADIANS)
|
||||
angle *= LINE_SNAP_RADIANS
|
||||
return (
|
||||
float(start[0]) + math.cos(angle) * length,
|
||||
float(start[1]) + math.sin(angle) * length,
|
||||
)
|
||||
|
||||
|
||||
def line_half_plane_polygon(start, end, rectangle_size):
|
||||
"""Clip a rectangle to the left side of one directed line."""
|
||||
width, height = (float(value) for value in rectangle_size)
|
||||
if math.dist(start, end) < 2.0:
|
||||
return ()
|
||||
vertices = [(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]
|
||||
|
||||
def side(point):
|
||||
return (end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (
|
||||
point[0] - start[0]
|
||||
)
|
||||
|
||||
result = []
|
||||
previous = vertices[-1]
|
||||
previous_side = side(previous)
|
||||
for current in vertices:
|
||||
current_side = side(current)
|
||||
if (current_side >= 0.0) != (previous_side >= 0.0):
|
||||
factor = previous_side / (previous_side - current_side)
|
||||
result.append(
|
||||
(
|
||||
previous[0] + (current[0] - previous[0]) * factor,
|
||||
previous[1] + (current[1] - previous[1]) * factor,
|
||||
)
|
||||
)
|
||||
if current_side >= 0.0:
|
||||
result.append(current)
|
||||
previous = current
|
||||
previous_side = current_side
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def image_line_half_plane_polygon(line, image_size):
|
||||
"""Keep the image-space half-plane shown by the screen-space preview."""
|
||||
if len(line) != 2:
|
||||
raise ValueError("Drag a longer line across the image plane")
|
||||
return line_half_plane_polygon(line[1], line[0], image_size)
|
||||
|
||||
|
||||
class ImageRegionGesture:
|
||||
active_tool_id = None
|
||||
preview_fill_rule = "EVEN_ODD"
|
||||
_path = None
|
||||
_cursor = None
|
||||
_handle = None
|
||||
_fill_polygon = None
|
||||
_fill_triangles = ()
|
||||
_outline_polygon = None
|
||||
_outline_segments = ()
|
||||
_area_pointer = 0
|
||||
_region_pointer = 0
|
||||
_region_size = (0, 0)
|
||||
|
||||
def draw(self, _context):
|
||||
draw_region_operator_settings(self.layout, self)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return image_edit_poll(context)
|
||||
|
||||
def _screen_path(self):
|
||||
if self.gesture == "BOX":
|
||||
return box_screen_path(self._path[0], self._cursor)
|
||||
if self.gesture == "LASSO":
|
||||
return tuple(self._path)
|
||||
if self.gesture == "LINE":
|
||||
return line_half_plane_polygon(
|
||||
self._path[0], self._cursor, self._region_size
|
||||
)
|
||||
vertices = list(self._path)
|
||||
if self._cursor is not None:
|
||||
vertices.append(self._cursor)
|
||||
return tuple(vertices)
|
||||
|
||||
def _draw_region(self):
|
||||
if not self._path or not drawing_in_region(
|
||||
self._area_pointer, self._region_pointer
|
||||
):
|
||||
return
|
||||
polygon = self._screen_path()
|
||||
if polygon != self._outline_polygon:
|
||||
self._outline_polygon = polygon
|
||||
if self.gesture == "LINE":
|
||||
outline = (self._path[0], self._cursor)
|
||||
else:
|
||||
outline = (*polygon, polygon[0]) if len(polygon) > 1 else ()
|
||||
self._outline_segments = dashed_line_segments(outline)
|
||||
if len(polygon) >= 3:
|
||||
if polygon != self._fill_polygon:
|
||||
self._fill_polygon = polygon
|
||||
self._fill_triangles = preview_fill_triangles(
|
||||
polygon, self.preview_fill_rule
|
||||
)
|
||||
draw_polygon_fill(
|
||||
polygon,
|
||||
self._fill_triangles,
|
||||
(1.0, 0.25, 0.2, 0.16) if self.invert else (1.0, 1.0, 1.0, 0.16),
|
||||
)
|
||||
if self.gesture == "LINE":
|
||||
draw_dashed_line((), segments=self._outline_segments)
|
||||
return
|
||||
if len(polygon) > 1:
|
||||
draw_dashed_line((), segments=self._outline_segments)
|
||||
if (
|
||||
self.gesture == "POLYLINE"
|
||||
and len(self._path) >= 3
|
||||
and self._cursor is not None
|
||||
and math.dist(self._cursor, self._path[0]) <= POLYLINE_CLOSE_RADIUS
|
||||
):
|
||||
self._draw_polyline_close_indicator()
|
||||
|
||||
def _draw_polyline_close_indicator(self):
|
||||
import gpu
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
|
||||
center_x, center_y = self._path[0]
|
||||
vertices = tuple(
|
||||
(
|
||||
center_x + math.cos(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
|
||||
center_y + math.sin(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
|
||||
)
|
||||
for index in range(25)
|
||||
)
|
||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||
batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices})
|
||||
gpu.state.blend_set("ALPHA")
|
||||
gpu.state.line_width_set(2.0)
|
||||
shader.bind()
|
||||
shader.uniform_float("color", (1.0, 1.0, 1.0, 0.95))
|
||||
batch.draw(shader)
|
||||
gpu.state.line_width_set(1.0)
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
def _set_status(self, context):
|
||||
invert = "On" if self.invert else "Off"
|
||||
if self.gesture == "POLYLINE":
|
||||
action = "click points; click start or Enter to finish; Backspace removes"
|
||||
elif self.gesture == "LINE":
|
||||
action = "drag line; Ctrl snaps 5°; release to edit"
|
||||
else:
|
||||
action = "drag and release to edit"
|
||||
context.workspace.status_text_set(
|
||||
f"Image {self.gesture.title()}: {action}; F toggles Invert ({invert}); "
|
||||
"RMB or Esc cancels"
|
||||
)
|
||||
|
||||
def _finish(self, context):
|
||||
if self._handle is not None:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
|
||||
self._handle = None
|
||||
context.workspace.status_text_set(None)
|
||||
if context.area is not None:
|
||||
context.area.tag_redraw()
|
||||
|
||||
def cancel(self, context):
|
||||
self._finish(context)
|
||||
|
||||
def invoke(self, context, event):
|
||||
source_object, should_edit = resolve_image_edit_click(
|
||||
context, event, self.report
|
||||
)
|
||||
if not should_edit:
|
||||
return {"FINISHED"} if source_object is not None else {"CANCELLED"}
|
||||
if source_object is None:
|
||||
return {"CANCELLED"}
|
||||
if video_total_frames(source_object.data) > 1:
|
||||
self.report({"WARNING"}, "Image tools only support a still image")
|
||||
return {"CANCELLED"}
|
||||
apply_region_settings(self, context)
|
||||
self.invert = False
|
||||
self.source_object_name = source_object.name
|
||||
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
|
||||
self.region_path_json = ""
|
||||
point = (float(event.mouse_region_x), float(event.mouse_region_y))
|
||||
self._path = [point]
|
||||
self._cursor = point
|
||||
self._fill_polygon = None
|
||||
self._fill_triangles = ()
|
||||
self._outline_polygon = None
|
||||
self._outline_segments = ()
|
||||
self._area_pointer = context.area.as_pointer()
|
||||
self._region_pointer = context.region.as_pointer()
|
||||
self._region_size = (context.region.width, context.region.height)
|
||||
self._handle = bpy.types.SpaceView3D.draw_handler_add(
|
||||
self._draw_region, (), "WINDOW", "POST_PIXEL"
|
||||
)
|
||||
self._set_status(context)
|
||||
context.window_manager.modal_handler_add(self)
|
||||
context.area.tag_redraw()
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _complete(self, context):
|
||||
source_object = require_image_empty(self.source_object_name)
|
||||
matrix = deserialize_matrix(self.source_matrix_data)
|
||||
if self.gesture == "LINE":
|
||||
line = screen_path_to_image_pixels(
|
||||
(self._path[0], self._cursor),
|
||||
context.region,
|
||||
context.region_data,
|
||||
matrix,
|
||||
image_empty_bounds(source_object),
|
||||
tuple(source_object.data.size),
|
||||
)
|
||||
region_path = image_line_half_plane_polygon(
|
||||
line, tuple(source_object.data.size)
|
||||
)
|
||||
else:
|
||||
path = self._screen_path()
|
||||
if len(path) < 3:
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
region_path = screen_path_to_image_pixels(
|
||||
path,
|
||||
context.region,
|
||||
context.region_data,
|
||||
matrix,
|
||||
image_empty_bounds(source_object),
|
||||
tuple(source_object.data.size),
|
||||
)
|
||||
self.region_path_json = RegionPath(
|
||||
points=region_path,
|
||||
invert=self.invert,
|
||||
fill_rule=self.preview_fill_rule,
|
||||
).to_json()
|
||||
self._finish(context)
|
||||
return self.execute(context)
|
||||
|
||||
def modal(self, context, event):
|
||||
active_tool = active_view3d_tool_id(context)
|
||||
expected_tool = self.active_tool_id or GESTURE_TOOL_IDS[self.gesture]
|
||||
if active_tool is not None and active_tool != expected_tool:
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
if event.type == "F" and event.value == "PRESS":
|
||||
self.invert = not self.invert
|
||||
self._set_status(context)
|
||||
context.area.tag_redraw()
|
||||
return {"RUNNING_MODAL"}
|
||||
if self.gesture == "POLYLINE":
|
||||
return self._modal_polyline(context, event)
|
||||
if event.type == "MOUSEMOVE":
|
||||
point = (float(event.mouse_region_x), float(event.mouse_region_y))
|
||||
if self.gesture == "LASSO":
|
||||
append_lasso_point(self._path, point)
|
||||
self._cursor = point
|
||||
elif self.gesture == "LINE":
|
||||
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
|
||||
else:
|
||||
self._cursor = point
|
||||
context.area.tag_redraw()
|
||||
return {"RUNNING_MODAL"}
|
||||
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
||||
point = (float(event.mouse_region_x), float(event.mouse_region_y))
|
||||
if self.gesture == "LASSO":
|
||||
append_lasso_point(self._path, point)
|
||||
self._cursor = point
|
||||
elif self.gesture == "LINE":
|
||||
self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
|
||||
if math.dist(self._path[0], self._cursor) < 2.0:
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
else:
|
||||
self._cursor = point
|
||||
try:
|
||||
return self._complete(context)
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
report_image_edit_exception(self, error)
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
def _modal_polyline(self, context, event):
|
||||
if event.type == "BACK_SPACE" and event.value == "PRESS":
|
||||
if len(self._path) > 1:
|
||||
self._path.pop()
|
||||
context.area.tag_redraw()
|
||||
return {"RUNNING_MODAL"}
|
||||
if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS":
|
||||
return self._confirm_polyline(context)
|
||||
if event.type == "MOUSEMOVE":
|
||||
self._cursor = (
|
||||
float(event.mouse_region_x),
|
||||
float(event.mouse_region_y),
|
||||
)
|
||||
context.area.tag_redraw()
|
||||
return {"RUNNING_MODAL"}
|
||||
if event.type == "LEFTMOUSE" and event.value == "PRESS":
|
||||
point = (float(event.mouse_region_x), float(event.mouse_region_y))
|
||||
if (
|
||||
len(self._path) >= 3
|
||||
and math.dist(point, self._path[0]) <= POLYLINE_CLOSE_RADIUS
|
||||
):
|
||||
return self._confirm_polyline(context)
|
||||
if math.dist(point, self._path[-1]) >= 2.0:
|
||||
self._path.append(point)
|
||||
context.area.tag_redraw()
|
||||
return {"RUNNING_MODAL"}
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
def _confirm_polyline(self, context):
|
||||
self._cursor = self._path[0]
|
||||
try:
|
||||
return self._complete(context)
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
report_image_edit_exception(self, error)
|
||||
self._finish(context)
|
||||
return {"CANCELLED"}
|
||||
230
src/anyimage/common/image_region.py
Normal file
230
src/anyimage/common/image_region.py
Normal file
@@ -0,0 +1,230 @@
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ImageEditWarning(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionPath:
|
||||
points: tuple[tuple[float, float], ...]
|
||||
invert: bool = False
|
||||
fill_rule: str = "EVEN_ODD"
|
||||
|
||||
def __post_init__(self):
|
||||
points = tuple(
|
||||
(float(point[0]), float(point[1]))
|
||||
for point in self.points
|
||||
)
|
||||
region_path_bounds(points)
|
||||
if self.fill_rule not in {"EVEN_ODD", "NONZERO"}:
|
||||
raise ValueError(f"Unknown Region Path fill rule: {self.fill_rule}")
|
||||
object.__setattr__(self, "points", points)
|
||||
object.__setattr__(self, "invert", bool(self.invert))
|
||||
|
||||
def to_json(self):
|
||||
return json.dumps(
|
||||
{
|
||||
"points": self.points,
|
||||
"invert": self.invert,
|
||||
"fill_rule": self.fill_rule,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, value):
|
||||
data = json.loads(value)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("The Region Path data is invalid")
|
||||
return cls(
|
||||
points=tuple(data.get("points", ())),
|
||||
invert=bool(data.get("invert", False)),
|
||||
fill_rule=str(data.get("fill_rule", "EVEN_ODD")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionMask:
|
||||
values: object
|
||||
bounds: tuple[int, int, int, int]
|
||||
|
||||
def __post_init__(self):
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray(self.values, dtype=np.float32)
|
||||
bounds = tuple(int(value) for value in self.bounds)
|
||||
if values.ndim != 2:
|
||||
raise ValueError("The region mask must be two-dimensional")
|
||||
if len(bounds) != 4:
|
||||
raise ValueError("The region bounds are invalid")
|
||||
left, top, right, bottom = bounds
|
||||
if right <= left or bottom <= top:
|
||||
raise ValueError("The region bounds are empty")
|
||||
if values.shape != (bottom - top, right - left):
|
||||
raise ValueError("The region mask does not match its bounds")
|
||||
if not np.isfinite(values).all():
|
||||
raise ValueError("The region mask contains invalid values")
|
||||
object.__setattr__(self, "values", np.clip(values, 0.0, 1.0))
|
||||
object.__setattr__(self, "bounds", bounds)
|
||||
|
||||
def full_values(self, image_size):
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image_size)
|
||||
result = np.zeros((height, width), dtype=np.float32)
|
||||
left, top, right, bottom = self.bounds
|
||||
source_left = max(left, 0)
|
||||
source_top = max(top, 0)
|
||||
source_right = min(right, width)
|
||||
source_bottom = min(bottom, height)
|
||||
if source_right <= source_left or source_bottom <= source_top:
|
||||
return result
|
||||
result[source_top:source_bottom, source_left:source_right] = self.values[
|
||||
source_top - top : source_bottom - top,
|
||||
source_left - left : source_right - left,
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def region_path_bounds(points):
|
||||
import numpy as np
|
||||
|
||||
points = np.asarray(points, dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
|
||||
raise ValueError("The region does not define a valid image path")
|
||||
if not np.isfinite(points).all():
|
||||
raise ValueError("The region contains invalid image coordinates")
|
||||
left = int(math.floor(float(points[:, 0].min())))
|
||||
top = int(math.floor(float(points[:, 1].min())))
|
||||
right = int(math.ceil(float(points[:, 0].max())))
|
||||
bottom = int(math.ceil(float(points[:, 1].max())))
|
||||
if right <= left or bottom <= top:
|
||||
raise ValueError("The region does not define a valid image area")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _rasterize_path(image_size, points, fill_rule):
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image_size)
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("The source image has no readable dimensions")
|
||||
left, top, right, bottom = region_path_bounds(points)
|
||||
left = max(left, 0)
|
||||
top = max(top, 0)
|
||||
right = min(right, width)
|
||||
bottom = min(bottom, height)
|
||||
mask = np.zeros((height, width), dtype=bool)
|
||||
if right <= left or bottom <= top:
|
||||
return mask
|
||||
|
||||
points = np.asarray(points, dtype=np.float64)
|
||||
x = np.arange(left, right, dtype=np.float64) + 0.5
|
||||
for row_start in range(top, bottom, 128):
|
||||
row_end = min(row_start + 128, bottom)
|
||||
y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
|
||||
state = np.zeros(
|
||||
(len(y), len(x)),
|
||||
dtype=bool if fill_rule == "EVEN_ODD" else np.int16,
|
||||
)
|
||||
previous = points[-1]
|
||||
for current in points:
|
||||
crosses = (current[1] > y) != (previous[1] > y)
|
||||
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
|
||||
previous[1] - current[1] + 1e-300
|
||||
) + current[0]
|
||||
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
|
||||
if fill_rule == "EVEN_ODD":
|
||||
state ^= selected
|
||||
else:
|
||||
winding = 1 if current[1] > previous[1] else -1
|
||||
state += selected * winding
|
||||
previous = current
|
||||
mask[row_start:row_end, left:right] = state != 0
|
||||
return mask
|
||||
|
||||
|
||||
def _antialias_mask(mask):
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray(mask, dtype=np.float32)
|
||||
kernel = np.asarray((0.029, 0.235, 0.472, 0.235, 0.029), dtype=np.float32)
|
||||
radius = len(kernel) // 2
|
||||
horizontal = np.zeros_like(values)
|
||||
padded = np.pad(values, ((0, 0), (radius, radius)), mode="edge")
|
||||
for offset, weight in enumerate(kernel):
|
||||
horizontal += padded[:, offset : offset + values.shape[1]] * weight
|
||||
result = np.zeros_like(values)
|
||||
padded = np.pad(horizontal, ((radius, radius), (0, 0)), mode="edge")
|
||||
for offset, weight in enumerate(kernel):
|
||||
result += padded[offset : offset + values.shape[0]] * weight
|
||||
return result
|
||||
|
||||
|
||||
def rasterize_region_path(
|
||||
image_size,
|
||||
region_path,
|
||||
*,
|
||||
source_alpha=None,
|
||||
alpha_threshold=0.0,
|
||||
antialias=False,
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
width, height = (int(value) for value in image_size)
|
||||
mask = _rasterize_path(
|
||||
(width, height),
|
||||
region_path.points,
|
||||
region_path.fill_rule,
|
||||
)
|
||||
if antialias:
|
||||
mask = _antialias_mask(mask)
|
||||
else:
|
||||
mask = mask.astype(np.float32)
|
||||
if region_path.invert:
|
||||
mask = 1.0 - mask
|
||||
if source_alpha is not None:
|
||||
alpha = np.asarray(source_alpha)
|
||||
if alpha.shape != (height, width):
|
||||
raise ValueError("The source Alpha does not match the image region")
|
||||
mask = np.where(alpha > float(alpha_threshold), mask, 0.0)
|
||||
visible_y, visible_x = np.nonzero(mask > float(alpha_threshold))
|
||||
if not len(visible_x):
|
||||
raise ImageEditWarning("The region contains no visible image pixels")
|
||||
bounds = (
|
||||
int(visible_x.min()),
|
||||
int(visible_y.min()),
|
||||
int(visible_x.max()) + 1,
|
||||
int(visible_y.max()) + 1,
|
||||
)
|
||||
left, top, right, bottom = bounds
|
||||
return RegionMask(mask[top:bottom, left:right], bounds)
|
||||
|
||||
|
||||
def crop_pixels_to_alpha(pixels, bounds):
|
||||
"""Crop Blender-order RGBA pixels to their nonzero Alpha bounds."""
|
||||
import numpy as np
|
||||
|
||||
left, top, right, bottom = bounds
|
||||
width = right - left
|
||||
height = bottom - top
|
||||
rgba = np.flipud(
|
||||
np.asarray(pixels, dtype=np.float32).reshape((height, width, 4))
|
||||
)
|
||||
visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0)
|
||||
if not len(visible_x):
|
||||
raise ImageEditWarning("The edit contains no visible image pixels")
|
||||
content_left = int(visible_x.min())
|
||||
content_top = int(visible_y.min())
|
||||
content_right = int(visible_x.max()) + 1
|
||||
content_bottom = int(visible_y.max()) + 1
|
||||
output = rgba[content_top:content_bottom, content_left:content_right].copy()
|
||||
return np.flipud(output).ravel(), (
|
||||
left + content_left,
|
||||
top + content_top,
|
||||
left + content_right,
|
||||
top + content_bottom,
|
||||
)
|
||||
47
src/anyimage/common/node.py
Normal file
47
src/anyimage/common/node.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
NODE_ASSET_NAME = "O_AnyImage.blend"
|
||||
|
||||
|
||||
def node_asset_path():
|
||||
return Path(__file__).resolve().parents[1] / "assets" / NODE_ASSET_NAME
|
||||
|
||||
|
||||
def load_node_group(group_name):
|
||||
existing = bpy.data.node_groups.get(group_name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
asset_path = node_asset_path()
|
||||
if not asset_path.is_file():
|
||||
raise RuntimeError(f"Missing node asset: {asset_path}")
|
||||
if bpy.app.version < (5, 0, 0):
|
||||
directory = str(asset_path) + "/NodeTree/"
|
||||
result = bpy.ops.wm.append(
|
||||
filepath=directory + group_name,
|
||||
directory=directory,
|
||||
filename=group_name,
|
||||
link=False,
|
||||
do_reuse_local_id=True,
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Unable to append the node asset")
|
||||
node_group = bpy.data.node_groups.get(group_name)
|
||||
if node_group is None:
|
||||
raise RuntimeError("Appended node asset is unavailable")
|
||||
node_group.use_fake_user = True
|
||||
return node_group
|
||||
|
||||
with bpy.data.libraries.load(str(asset_path), link=True) as (source, target):
|
||||
if group_name not in source.node_groups:
|
||||
raise RuntimeError(f"Node asset is missing {group_name}")
|
||||
target.node_groups = [group_name]
|
||||
linked_group = target.node_groups[0]
|
||||
node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group)
|
||||
if linked_group != node_group and linked_group.users == 0:
|
||||
bpy.data.node_groups.remove(linked_group)
|
||||
node_group.use_fake_user = True
|
||||
return node_group
|
||||
304
src/anyimage/common/object.py
Normal file
304
src/anyimage/common/object.py
Normal file
@@ -0,0 +1,304 @@
|
||||
import bpy
|
||||
|
||||
from .image_data import (
|
||||
configure_image_user,
|
||||
image_base_name,
|
||||
image_pixels,
|
||||
is_animated_image,
|
||||
)
|
||||
from .node import load_node_group
|
||||
|
||||
|
||||
def modifier_input_identifier(node_group, name):
|
||||
for item in node_group.interface.items_tree:
|
||||
if item.item_type != "SOCKET" or item.in_out != "INPUT":
|
||||
continue
|
||||
if item.name == name:
|
||||
return item.identifier
|
||||
raise RuntimeError(f"Geometry Nodes input is missing: {name}")
|
||||
|
||||
|
||||
def _modifier_input_slots(modifier):
|
||||
"""Return the runtime input collection, or None for the legacy API."""
|
||||
properties = getattr(modifier, "properties", None)
|
||||
if properties is None:
|
||||
return None
|
||||
return getattr(properties, "inputs", None)
|
||||
|
||||
|
||||
def set_modifier_input(modifier, identifier, value):
|
||||
"""Assign a value to a Geometry Nodes modifier input socket."""
|
||||
inputs = _modifier_input_slots(modifier)
|
||||
if inputs is not None:
|
||||
# Blender 5.2+ exposes inputs as runtime RNA keyed by socket identifier.
|
||||
getattr(inputs, identifier).value = value
|
||||
else:
|
||||
# Blender 4.x exposes inputs as modifier ID properties.
|
||||
modifier[identifier] = value
|
||||
|
||||
|
||||
DISPLACEMENT_STRENGTH = 1.0
|
||||
DISPLACEMENT_MIDLEVEL = 0.0
|
||||
IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer"
|
||||
DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer"
|
||||
SHADELESS_NODE_GROUP_NAME = "O Shadeless"
|
||||
VIEW_TRANSFORM_COLOR_SPACES = (
|
||||
("ACES 1.3", "ACES 1.3 sRGB"),
|
||||
("ACES 2.0", "ACES 2.0 sRGB"),
|
||||
("AgX", "AgX Base sRGB"),
|
||||
("Filmic", "Filmic sRGB"),
|
||||
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
|
||||
)
|
||||
|
||||
|
||||
def material_color_space_name(scene):
|
||||
view_settings = getattr(scene, "view_settings", None)
|
||||
view_transform = getattr(view_settings, "view_transform", "")
|
||||
normalized = str(view_transform).strip().casefold()
|
||||
for view_name, color_space in VIEW_TRANSFORM_COLOR_SPACES:
|
||||
if normalized.startswith(view_name.casefold()):
|
||||
return color_space
|
||||
return "sRGB"
|
||||
|
||||
|
||||
def set_material_color_space(image, scene=None):
|
||||
if scene is None:
|
||||
scene = getattr(bpy.context, "scene", None)
|
||||
settings = image.colorspace_settings
|
||||
preferred = material_color_space_name(scene)
|
||||
original = settings.name
|
||||
pixels = None
|
||||
if preferred != original and (
|
||||
getattr(image, "source", None) == "GENERATED"
|
||||
or getattr(image, "is_dirty", False)
|
||||
):
|
||||
pixels = image_pixels(image)
|
||||
for color_space in dict.fromkeys((preferred, "sRGB", original)):
|
||||
if not color_space:
|
||||
continue
|
||||
try:
|
||||
settings.name = color_space
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if settings.name == color_space:
|
||||
if pixels is not None:
|
||||
image.pixels.foreach_set(pixels)
|
||||
image.update()
|
||||
return color_space
|
||||
return settings.name
|
||||
|
||||
|
||||
def material_node_group(depth_plane=False):
|
||||
group_name = (
|
||||
DEPTH_MATERIAL_NODE_GROUP_NAME
|
||||
if depth_plane
|
||||
else IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
)
|
||||
return load_node_group(group_name)
|
||||
|
||||
|
||||
def create_image_material(
|
||||
source_object,
|
||||
color_image,
|
||||
depth_scale,
|
||||
displacement_image=None,
|
||||
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",
|
||||
ior=None,
|
||||
scene=None,
|
||||
):
|
||||
material = bpy.data.materials.new(image_base_name(source_object.data))
|
||||
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")
|
||||
|
||||
uv_map.uv_map = "UVMap"
|
||||
uv_map.location = (-1250.0, 350.0)
|
||||
material_image = color_image
|
||||
set_material_color_space(material_image, scene)
|
||||
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 "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)
|
||||
if ior is not None:
|
||||
shader.inputs["IOR"].default_value = float(ior)
|
||||
|
||||
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(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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def set_displacement_only(material):
|
||||
for owner, property_name in (
|
||||
(material, "surface_displacement_method"),
|
||||
(material, "displacement_method"),
|
||||
(getattr(material, "cycles", None), "displacement_method"),
|
||||
):
|
||||
if owner is not None and hasattr(owner, property_name):
|
||||
setattr(owner, property_name, "DISPLACEMENT")
|
||||
return
|
||||
|
||||
|
||||
def finalize_object_result(
|
||||
context,
|
||||
source_object,
|
||||
result_object,
|
||||
*,
|
||||
keep_source=False,
|
||||
):
|
||||
"""Activate a result object and optionally replace its source object."""
|
||||
source_name = source_object.name
|
||||
for selected in context.selected_objects:
|
||||
selected.select_set(False)
|
||||
result_object.select_set(True)
|
||||
context.view_layer.objects.active = result_object
|
||||
if not keep_source:
|
||||
bpy.data.objects.remove(source_object, do_unlink=True)
|
||||
result_object.name = source_name
|
||||
return result_object
|
||||
Reference in New Issue
Block a user