Add image selection refinement functionality with BEN2 integration

- Implemented viewport.py for handling 3D view projections, gestures, and overlay drawing.
- Created refine_image_selection.py to manage the refinement of gesture masks and output image bounds.
- Added selection module with refine_selection.py to process and crop images based on alpha channel visibility.
- Introduced __init__.py for selection module to expose refine_selection function.
This commit is contained in:
2026-08-31 22:10:05 +08:00
parent 14d63001d2
commit c97b4a3e41
76 changed files with 2127 additions and 1137 deletions

View File

@@ -1 +1 @@
"""Shared Blender-side image, node, object, interaction, and AI Job helpers."""
"""Shared Blender-side AI, image, selection, viewport, and result helpers."""

View File

@@ -1,9 +1,17 @@
"""Shared Blender-side AI setup, validation, UI, and Job parameters."""
from pathlib import Path
import bpy
from ..preferences import addon_preferences
from ..properties import ai_ready, model_ready, model_record
from ..properties import (
ai_ready,
ai_setup_label,
ai_status,
model_ready,
model_record,
)
from ..runtime import runtime
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
@@ -12,6 +20,23 @@ BEN2_VIDEO_SUFFIXES = {
}
def draw_ai_setup(layout):
status = ai_status()
if not status["ready"]:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
return status
def draw_ai_property(layout, owner, name, status):
row = layout.row()
row.enabled = status["ready"]
row.prop(owner, name)
def require_input_path(value):
path = Path(bpy.path.abspath(value))
if path.is_file() or path.is_dir():

View File

@@ -5,7 +5,7 @@ from pathlib import Path
import bpy
from .image_data import depth_data_name, image_empty_bounds
from .image import depth_data_name, image_empty_bounds
def load_depth_metadata(path):

View File

@@ -1,3 +1,5 @@
"""Shared Blender Image and Image Empty lifecycle helpers."""
import re
import shutil
import tempfile
@@ -260,21 +262,21 @@ def prepare_image_input(image, scene):
return source_path, False
def prepare_region_input(image, region_mask):
"""Write one cropped RGBA Region input owned by its temporary directory."""
def prepare_selection_input(image, selection_mask):
"""Write one cropped RGBA Selection input owned by its temporary directory."""
import numpy as np
left, top, right, bottom = region_mask.bounds
left, top, right, bottom = selection_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
if cropped.shape[:2] != selection_mask.values.shape:
raise ValueError("The Selection does not match the source image")
cropped[:, :, 3] *= selection_mask.values
cropped[cropped[:, :, 3] <= 0.0, :3] = 0.0
output_directory = Path(tempfile.mkdtemp(prefix="anyimage-region-"))
output_directory = Path(tempfile.mkdtemp(prefix="anyimage-selection-"))
output_path = output_directory / "input.png"
temporary_image = bpy.data.images.new(
"AnyImage Region Input",
"AnyImage Selection Input",
width=right - left,
height=bottom - top,
alpha=True,
@@ -294,7 +296,7 @@ def prepare_region_input(image, region_mask):
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")
raise RuntimeError("Blender did not export the Selection input")
return output_path

View File

@@ -0,0 +1,261 @@
"""Build and configure Blender materials for Image results."""
import bpy
from .image import (
configure_image_user,
image_base_name,
image_pixels,
is_animated_image,
)
from .node import load_node_group
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

View File

@@ -1,12 +1,6 @@
import bpy
"""Shared Blender Object and Geometry Nodes Modifier helpers."""
from .image_data import (
configure_image_user,
image_base_name,
image_pixels,
is_animated_image,
)
from .node import load_node_group
import bpy
def modifier_input_identifier(node_group, name):
@@ -30,265 +24,15 @@ 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.
input_slot = getattr(inputs, identifier)
value_property = input_slot.bl_rna.properties["value"]
if value_property.type == "ENUM" and type(value) is int:
value = value_property.enum_items[value].identifier
input_slot.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,

View File

@@ -1,3 +1,5 @@
"""Selection values, serialization, rasterization, and Alpha cropping."""
import json
import math
from dataclasses import dataclass
@@ -8,7 +10,7 @@ class ImageEditWarning(ValueError):
@dataclass(frozen=True)
class RegionPath:
class SelectionPath:
points: tuple[tuple[float, float], ...]
invert: bool = False
fill_rule: str = "EVEN_ODD"
@@ -18,9 +20,9 @@ class RegionPath:
(float(point[0]), float(point[1]))
for point in self.points
)
region_path_bounds(points)
selection_path_bounds(points)
if self.fill_rule not in {"EVEN_ODD", "NONZERO"}:
raise ValueError(f"Unknown Region Path fill rule: {self.fill_rule}")
raise ValueError(f"Unknown Selection Path fill rule: {self.fill_rule}")
object.__setattr__(self, "points", points)
object.__setattr__(self, "invert", bool(self.invert))
@@ -38,7 +40,7 @@ class RegionPath:
def from_json(cls, value):
data = json.loads(value)
if not isinstance(data, dict):
raise ValueError("The Region Path data is invalid")
raise ValueError("The Selection Path data is invalid")
return cls(
points=tuple(data.get("points", ())),
invert=bool(data.get("invert", False)),
@@ -47,7 +49,7 @@ class RegionPath:
@dataclass(frozen=True)
class RegionMask:
class SelectionMask:
values: object
bounds: tuple[int, int, int, int]
@@ -57,16 +59,16 @@ class RegionMask:
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")
raise ValueError("The selection mask must be two-dimensional")
if len(bounds) != 4:
raise ValueError("The region bounds are invalid")
raise ValueError("The selection bounds are invalid")
left, top, right, bottom = bounds
if right <= left or bottom <= top:
raise ValueError("The region bounds are empty")
raise ValueError("The selection bounds are empty")
if values.shape != (bottom - top, right - left):
raise ValueError("The region mask does not match its bounds")
raise ValueError("The selection mask does not match its bounds")
if not np.isfinite(values).all():
raise ValueError("The region mask contains invalid values")
raise ValueError("The selection mask contains invalid values")
object.__setattr__(self, "values", np.clip(values, 0.0, 1.0))
object.__setattr__(self, "bounds", bounds)
@@ -89,20 +91,20 @@ class RegionMask:
return result
def region_path_bounds(points):
def selection_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")
raise ValueError("The selection does not define a valid image path")
if not np.isfinite(points).all():
raise ValueError("The region contains invalid image coordinates")
raise ValueError("The selection 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")
raise ValueError("The selection does not define a valid image area")
return left, top, right, bottom
@@ -112,7 +114,7 @@ def _rasterize_path(image_size, points, fill_rule):
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, top, right, bottom = selection_path_bounds(points)
left = max(left, 0)
top = max(top, 0)
right = min(right, width)
@@ -164,9 +166,9 @@ def _antialias_mask(mask):
return result
def rasterize_region_path(
def rasterize_selection_path(
image_size,
region_path,
selection_path,
*,
source_alpha=None,
alpha_threshold=0.0,
@@ -177,23 +179,23 @@ def rasterize_region_path(
width, height = (int(value) for value in image_size)
mask = _rasterize_path(
(width, height),
region_path.points,
region_path.fill_rule,
selection_path.points,
selection_path.fill_rule,
)
if antialias:
mask = _antialias_mask(mask)
else:
mask = mask.astype(np.float32)
if region_path.invert:
if selection_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")
raise ValueError("The source Alpha does not match the image selection")
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")
raise ImageEditWarning("The selection contains no visible image pixels")
bounds = (
int(visible_x.min()),
int(visible_y.min()),
@@ -201,7 +203,7 @@ def rasterize_region_path(
int(visible_y.max()) + 1,
)
left, top, right, bottom = bounds
return RegionMask(mask[top:bottom, left:right], bounds)
return SelectionMask(mask[top:bottom, left:right], bounds)
def crop_pixels_to_alpha(pixels, bounds):

View File

@@ -1,16 +1,19 @@
"""View3D projection, gestures, tool settings, and overlay drawing."""
import json
import math
import bpy
from ..properties import ai_ready, ai_setup_label, ai_status
from .image_data import (
from ..properties import ai_ready
from .ai import draw_ai_property, draw_ai_setup
from .image import (
image_empty_bounds,
is_image_empty,
require_image_empty,
video_total_frames,
)
from .image_region import ImageEditWarning, RegionPath
from .selection import ImageEditWarning, SelectionPath
MIN_PATH_DISTANCE = 2.0
@@ -148,46 +151,29 @@ def active_view3d_tool_id(context):
return getattr(tool, "idname", None)
def refine_region_setting_name(operator):
setting_names = getattr(operator, "refine_region_settings", {})
def refine_selection_setting_name(operator):
setting_names = getattr(operator, "refine_selection_settings", {})
return setting_names.get(operator.gesture)
def apply_region_settings(operator, context):
def apply_selection_settings(operator, context):
settings = context.scene.anyimage_settings
setting_name = refine_region_setting_name(operator)
operator.refine_region = (
setting_name = refine_selection_setting_name(operator)
operator.refine_selection = (
setting_name is not None and ai_ready() and getattr(settings, setting_name)
)
def draw_ai_setup(layout):
status = ai_status()
if not status["ready"]:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
return status
def draw_ai_property(layout, owner, name, status):
row = layout.row()
row.enabled = status["ready"]
row.prop(owner, name)
def draw_region_operator_settings(layout, operator):
def draw_selection_operator_settings(layout, operator):
if operator.gesture not in {"BOX", "LASSO"}:
layout.prop(operator, "invert")
return
status = draw_ai_setup(layout)
draw_ai_property(layout, operator, "refine_region", status)
draw_ai_property(layout, operator, "refine_selection", status)
layout.prop(operator, "invert")
def draw_region_settings(context, layout, setting_name):
def draw_selection_settings(context, layout, setting_name):
settings = context.scene.anyimage_settings
status = draw_ai_setup(layout)
draw_ai_property(layout, settings, setting_name, status)
@@ -621,7 +607,7 @@ def image_line_half_plane_polygon(line, image_size):
return line_half_plane_polygon(line[1], line[0], image_size)
class ImageRegionGesture:
class ImageSelectionGesture:
active_tool_id = None
preview_fill_rule = "EVEN_ODD"
_path = None
@@ -636,7 +622,7 @@ class ImageRegionGesture:
_region_size = (0, 0)
def draw(self, _context):
draw_region_operator_settings(self.layout, self)
draw_selection_operator_settings(self.layout, self)
@classmethod
def poll(cls, context):
@@ -656,7 +642,7 @@ class ImageRegionGesture:
vertices.append(self._cursor)
return tuple(vertices)
def _draw_region(self):
def _draw_selection(self):
if not self._path or not drawing_in_region(
self._area_pointer, self._region_pointer
):
@@ -750,11 +736,11 @@ class ImageRegionGesture:
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)
apply_selection_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 = ""
self.selection_path_json = ""
point = (float(event.mouse_region_x), float(event.mouse_region_y))
self._path = [point]
self._cursor = point
@@ -766,7 +752,7 @@ class ImageRegionGesture:
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._draw_selection, (), "WINDOW", "POST_PIXEL"
)
self._set_status(context)
context.window_manager.modal_handler_add(self)
@@ -785,7 +771,7 @@ class ImageRegionGesture:
image_empty_bounds(source_object),
tuple(source_object.data.size),
)
region_path = image_line_half_plane_polygon(
selection_path = image_line_half_plane_polygon(
line, tuple(source_object.data.size)
)
else:
@@ -793,7 +779,7 @@ class ImageRegionGesture:
if len(path) < 3:
self._finish(context)
return {"CANCELLED"}
region_path = screen_path_to_image_pixels(
selection_path = screen_path_to_image_pixels(
path,
context.region,
context.region_data,
@@ -801,8 +787,8 @@ class ImageRegionGesture:
image_empty_bounds(source_object),
tuple(source_object.data.size),
)
self.region_path_json = RegionPath(
points=region_path,
self.selection_path_json = SelectionPath(
points=selection_path,
invert=self.invert,
fill_rule=self.preview_fill_rule,
).to_json()