Initial public release
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-09-19 21:31:36 +08:00
commit 198fcbf55b
837 changed files with 67239 additions and 0 deletions

View File

198
tests/support/blender.py Normal file
View File

@@ -0,0 +1,198 @@
from tests.support.paths import PROJECT_ROOT
import importlib
import builtins
import sys
import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
SOURCE_ROOT = PROJECT_ROOT / "src"
def mock_bpy_imports(fake_bpy):
"""Stub Python imports while preserving Blender's native module identity."""
original_import = builtins.__import__
def import_with_fake_bpy(name, globals=None, locals=None, fromlist=(), level=0):
if name == "bpy" and level == 0:
return fake_bpy
return original_import(name, globals, locals, fromlist, level)
return patch("builtins.__import__", import_with_fake_bpy)
class BlenderTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.previous_addon_modules = {
name: module for name, module in sys.modules.items()
if name == "anyimage" or name.startswith("anyimage.")
}
cls.remove_modules()
def draw_status_bar(_owner, _context):
pass
draw_status_bar._draw_funcs = [draw_status_bar]
def append_status_bar(draw):
draw._owner = "anyimage"
draw_status_bar._draw_funcs.append(draw)
cls.status_bar_draws.append(draw)
def remove_status_bar(draw):
draw_status_bar._draw_funcs.remove(draw)
cls.status_bar_draws.remove(draw)
fake_bpy = ModuleType("bpy")
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
FloatVectorProperty=lambda **options: options,
IntProperty=lambda **options: options,
PointerProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(
AddonPreferences=object,
Menu=object,
Operator=object,
Panel=object,
PropertyGroup=object,
WorkSpaceTool=object,
Image=type("Image", (), {}),
Scene=type("Scene", (), {}),
STATUSBAR_HT_header=SimpleNamespace(
draw=draw_status_bar,
append=append_status_bar,
remove=remove_status_bar,
),
VIEW3D_MT_object_context_menu=SimpleNamespace(
append=lambda draw: cls.context_menu_draws.append(draw),
prepend=lambda draw: cls.context_menu_draws.insert(0, draw),
remove=lambda draw: cls.context_menu_draws.remove(draw),
),
OUTLINER_MT_object=SimpleNamespace(
append=lambda draw: cls.outliner_menu_draws.append(draw),
prepend=lambda draw: cls.outliner_menu_draws.insert(0, draw),
remove=lambda draw: cls.outliner_menu_draws.remove(draw),
),
NODE_MT_context_menu=SimpleNamespace(
prepend=lambda draw: cls.node_menu_draws.insert(0, draw),
remove=lambda draw: cls.node_menu_draws.remove(draw),
),
)
cls.registered = []
cls.registered_tools = []
cls.status_bar_draws = []
cls.context_menu_draws = []
cls.outliner_menu_draws = []
cls.node_menu_draws = []
fake_bpy.utils = SimpleNamespace(
register_class=cls.registered.append,
unregister_class=cls.registered.remove,
register_tool=lambda tool, **options: cls.registered_tools.append(
(tool, options)
),
unregister_tool=lambda tool: cls.registered_tools.remove(
next(item for item in cls.registered_tools if item[0] is tool)
),
)
fake_bpy.context = SimpleNamespace(
preferences=SimpleNamespace(addons={}),
)
fake_bpy.app = SimpleNamespace(online_access=True)
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
fake_bpy.data = SimpleNamespace(
objects=SimpleNamespace(get=lambda _name: None),
)
cls.bpy_import_patch = mock_bpy_imports(fake_bpy)
cls.bpy_import_patch.start()
cls.addClassCleanup(cls.bpy_import_patch.stop)
sys.path.insert(0, str(SOURCE_ROOT))
cls.paths_before_import = tuple(sys.path)
cls.anyimage = importlib.import_module("anyimage")
cls.runtime = importlib.import_module("anyimage.runtime")
cls.ai_setup = importlib.import_module("anyimage.operators.ai_setup")
cls.ai = importlib.import_module("anyimage.common.ai")
cls.image_data = importlib.import_module("anyimage.common.image")
cls.convert_to_plane = importlib.import_module(
"anyimage.operators.convert_to_plane.operators"
)
cls.convert_to_plane_object = importlib.import_module(
"anyimage.operators.convert_to_plane.object"
)
cls.cutout_main = importlib.import_module(
"anyimage.operators.cutout_tool.operators"
)
cls.remove_background = importlib.import_module(
"anyimage.operators.remove_background"
)
cls.upscale = importlib.import_module("anyimage.operators.upscale")
cls.image_interaction = importlib.import_module("anyimage.common.viewport")
cls.edit_mask = importlib.import_module(
"anyimage.operators.mask_tool"
)
cls.image_selection = importlib.import_module("anyimage.common.selection")
cls.image_frame = importlib.import_module(
"anyimage.operators.frame_tool.operators"
)
cls.rectify = importlib.import_module(
"anyimage.operators.rectify_tool.operators"
)
cls.rectify_geometry = importlib.import_module("anyimage.operators.rectify_tool.geometry")
cls.rectify_preview = importlib.import_module("anyimage.operators.rectify_tool.preview")
cls.cutout_geometry = importlib.import_module(
"anyimage.operators.cutout_tool.geometry"
)
cls.paths_after_import = tuple(sys.path)
cls.fake_bpy = fake_bpy
@classmethod
def tearDownClass(cls):
sys.path.remove(str(SOURCE_ROOT))
cls.remove_modules()
sys.modules.update(cls.previous_addon_modules)
@staticmethod
def remove_modules():
for module_name in tuple(sys.modules):
if module_name == "anyimage" or module_name.startswith("anyimage."):
sys.modules.pop(module_name)
def setUp(self):
timer_callbacks = set()
self.fake_bpy.app.timers = SimpleNamespace(
is_registered=timer_callbacks.__contains__,
register=lambda callback, **options: timer_callbacks.add(callback),
unregister=timer_callbacks.remove,
)
preferences = SimpleNamespace(
storage_root="",
device="CUDA",
upscale_model="REALESRGAN_GENERAL_WDN_X4V3",
max_ai_input_size=2048,
)
self.fake_bpy.context.preferences.addons = {
"anyimage": SimpleNamespace(preferences=preferences)
}
def tearDown(self):
self.anyimage.runtime.close_active()
self.registered.clear()
self.registered_tools.clear()
self.status_bar_draws.clear()
self.context_menu_draws.clear()
self.outliner_menu_draws.clear()
self.node_menu_draws.clear()
self.fake_bpy.data = SimpleNamespace(
objects=SimpleNamespace(get=lambda _name: None),
)
if hasattr(self.fake_bpy.types.Scene, "anyimage_settings"):
del self.fake_bpy.types.Scene.anyimage_settings

View File

@@ -0,0 +1,11 @@
"""Prepare a standalone color image for material and geometry tests."""
from anyimage.common.color_image import prepare_material_color_input, load_material_color_image, cleanup_material_color_input
def color_image(source, bounds=None, rgba=None):
path = prepare_material_color_input(source, bounds, rgba)
try:
return load_material_color_image(path)
finally:
cleanup_material_color_input(path)

View File

@@ -0,0 +1,133 @@
"""Behavior checks for face separation and connected Depth Surface shells."""
from collections import Counter
import bpy
import numpy as np
from tests.support.nodes import CUTOUT_BUILDERS
def surface(*, step=6.0, size=1.0, resolution=2):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
# Two columns with a complete depth jump along their common boundary.
points = [
(x * size / resolution, 0, y * size / resolution)
for y in range(resolution + 1)
for x in range(2 * resolution + 1)
]
stride = 2 * resolution + 1
faces = [
(
y * stride + x,
y * stride + x + 1,
(y + 1) * stride + x + 1,
(y + 1) * stride + x,
)
for y in range(resolution)
for x in range(2 * resolution)
]
mesh = bpy.data.meshes.new("Step")
mesh.from_pydata(points, [], faces)
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
x, _y, z = points[loop.vertex_index]
uv.data[loop.index].uv = (x / (2 * size), z / size)
obj = bpy.data.objects.new("Step", mesh)
bpy.context.collection.objects.link(obj)
image = bpy.data.images.new("Camera", width=64, height=32, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
yy, xx = np.mgrid[:32, :64]
pixels = np.ones((32, 64, 4), np.float32)
pixels[..., 0] = (xx + 0.5) / 32
pixels[..., 1] = -(yy + 0.5) / 32
pixels[..., 2] = 1 + (xx >= 32) * step
image.pixels.foreach_set(pixels.ravel())
modifier = obj.modifiers.new("Depth Surface", "NODES")
modifier.node_group = group
inputs = {
s.name: s.identifier
for s in group.interface.items_tree
if s.item_type == "SOCKET" and s.in_out == "INPUT"
}
def set_value(name, value):
modifier[inputs[name]] = value
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
for name, value in (
("Depth Image", image),
("Uniform Scale", size),
("Reference Depth", size * (1 + step / 2)),
("Depth Scale", 1.0),
("Thickness", 0.0),
("Front Inflation", 0.0),
("Depth Split", 0.0),
("Mode", 1),
("Boundary Smooth", 0),
):
set_value(name, value)
return obj, set_value
def evaluated(obj):
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
return np.array([v.co[:] for v in mesh.vertices]), [
tuple(p.vertices) for p in mesh.polygons
]
finally:
result.to_mesh_clear()
def assert_closed(faces):
edges = Counter(
tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))
)
assert edges and set(edges.values()) == {2}, Counter(edges.values())
def assert_single_vertex_fans(faces):
"""Every vertex must close one face fan so touching sheets stay identifiable."""
incident = {}
for index, face in enumerate(faces):
for start, end in zip(face, (*face[1:], face[0])):
edge = tuple(sorted((start, end)))
incident.setdefault(start, {}).setdefault(edge, []).append(index)
incident.setdefault(end, {}).setdefault(edge, []).append(index)
for vertex, edges in incident.items():
shared = {}
for faces_at_edge in edges.values():
assert len(faces_at_edge) == 2, (vertex, faces_at_edge)
first, second = faces_at_edge
shared.setdefault(first, set()).add(second)
shared.setdefault(second, set()).add(first)
assert all(len(neighbors) == 2 for neighbors in shared.values()), vertex
reached, pending = {next(iter(shared))}, [next(iter(shared))]
while pending:
for neighbor in shared[pending.pop()] - reached:
reached.add(neighbor)
pending.append(neighbor)
assert len(reached) == len(shared), vertex
def component_count(faces):
adjacency = {}
for face in faces:
for a, b in zip(face, (*face[1:], face[0])):
adjacency.setdefault(a, set()).add(b)
adjacency.setdefault(b, set()).add(a)
remaining = set(adjacency)
count = 0
while remaining:
count += 1
pending = [remaining.pop()]
while pending:
for neighbor in adjacency[pending.pop()] & remaining:
remaining.remove(neighbor)
pending.append(neighbor)
return count

View File

@@ -0,0 +1,15 @@
import bpy
import numpy as np
import pytest
@pytest.fixture
def hdr_texture(texture):
image = bpy.data.images.new("HDR source", width=4, height=3, alpha=True, float_buffer=True)
image.alpha_mode = "PREMUL"
rgba = np.tile([4.0, 2.0, -0.25, 0.5], (3, 4, 1)).astype(np.float32)
rgba[0, 0] = [9, 3, -0.5, 0]
rgba[2, 3] = [25, 12, 6, 1]
image.pixels.foreach_set(np.flipud(rgba).ravel())
texture.image = image
return texture

View File

@@ -0,0 +1,108 @@
import bpy
import numpy as np
from anyimage.common import image as image_data
from anyimage.common.selection import SelectionMask
from anyimage.operators.cutout_tool import geometry as cutout_geometry
from anyimage.operators.cutout_tool import object as cutout_object
from tests.support.color_image import color_image as prepared_color_image
def _inputs(group):
return [
item.name
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
def _group_node(group, node_tree):
return next(
node
for node in node_tree.nodes
if node.bl_idname == "ShaderNodeGroup" and node.node_tree == group
)
def _create_cutout_shape(
source,
shape,
edge_length,
*,
bounds=None,
depth_image=None,
depth_metadata=None,
selection_values=None,
gesture="LASSO",
):
if bounds is None:
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
left, top, right, bottom = bounds
if selection_values is None:
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
selection_mask = SelectionMask(selection_values, bounds)
source_rgba = image_data.image_rgba(source.data)
color_image = prepared_color_image(
source.data,
selection_mask.bounds,
source_rgba,
)
base_shape = cutout_geometry.build_base_shape(
bpy.context,
source,
edge_length,
selection_mask.values,
selection_mask.bounds,
)
return cutout_object.create_shape_object(
bpy.context,
source,
shape,
selection_mask.values,
selection_mask.bounds,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
gesture=gesture,
color_image=color_image,
)
def _depth_image(points, alpha, name="Depth"):
points = np.asarray(points, dtype=np.float32)
alpha = np.asarray(alpha, dtype=np.float32)
height, width = alpha.shape
pixels = np.zeros((height, width, 4), dtype=np.float32)
pixels[..., :3] = points
pixels[..., 3] = alpha
image = bpy.data.images.new(
name,
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def _depth_metadata(intrinsics, image_size):
return {
"intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics),
"image_size": tuple(int(value) for value in image_size),
}
def _evaluated_positions(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
return np.asarray([tuple(vertex.co) for vertex in result.vertices])
finally:
evaluated.to_mesh_clear()

View File

@@ -0,0 +1,45 @@
from types import SimpleNamespace
import bpy
import numpy as np
import pytest
from PIL import Image
@pytest.fixture
def texture():
bpy.ops.wm.read_factory_settings(use_empty=True)
material = bpy.data.materials.new("Texture material")
material.use_nodes = True
material.use_fake_user = True
tree = material.node_tree
node = tree.nodes.new("ShaderNodeTexImage")
node.name = "Source texture"
node.image = bpy.data.images.new("Texture.png", width=4, height=3, alpha=True)
node.image.pixels.foreach_set(np.tile([0.2, 0.4, 0.6, 0.75], 12).astype(np.float32))
node.image.pack()
node.location = (-400, 100)
node.interpolation = "Closest"
node.extension = "CLIP"
node.projection = "BOX"
tree.nodes.active = node
tree.links.new(node.outputs["Color"], tree.nodes.get("Principled BSDF").inputs["Base Color"])
yield node
bpy.ops.wm.read_factory_settings(use_empty=True)
def texture_context(node, **space_options):
return SimpleNamespace(
space_data=SimpleNamespace(
**dict(type="NODE_EDITOR", tree_type="ShaderNodeTree", shader_type="OBJECT",
edit_tree=node.id_data, id=bpy.data.materials["Texture material"], **space_options),
),
object=None,
scene=SimpleNamespace(anyimage_settings=SimpleNamespace()),
)
def write_result(directory, filename="foreground.png"):
path = directory / filename
Image.new("RGBA", (8, 6), (128, 64, 32, 80)).save(path)
return path

View File

@@ -0,0 +1,11 @@
import bpy
import pytest
from nodes.groups.image_layer import build_image_layer_group
@pytest.fixture
def image_layer():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_image_layer_group()
yield group
bpy.ops.wm.read_factory_settings(use_empty=True)

11
tests/support/nodes.py Normal file
View File

@@ -0,0 +1,11 @@
"""Shared assertions and asset selection for node tests."""
from nodes.groups.image_cutout import build_image_cutout_group
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
CUTOUT_BUILDERS = {
"FLAT": build_image_cutout_group,
"SOLID": build_image_cutout_group,
"DEPTH_SOLID": build_image_depth_cutout_group,
"DEPTH_SYMMETRY": build_image_cutout_symmetry_group,
}

4
tests/support/paths.py Normal file
View File

@@ -0,0 +1,4 @@
"""Resolve project paths independently of test directory depth."""
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]

79
tests/support/planes.py Normal file
View File

@@ -0,0 +1,79 @@
"""Evaluate complete-image projection and rectangular surface topology."""
import bpy
import numpy as np
import pytest
from nodes.groups.image_plane import build_image_plane_group
from nodes.groups.image_depth_plane import build_image_depth_plane_group
from nodes.groups.image_relief_plane import build_image_relief_plane_group
@pytest.fixture
def surface():
return create_surface("DEPTH")
@pytest.fixture
def relief():
return create_surface("RELIEF")
def create_surface(plane_type):
bpy.ops.wm.read_factory_settings(use_empty=True)
plane = build_image_plane_group()
builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group
group = builder(plane)
mesh = bpy.data.meshes.new("Rectangle")
mesh.from_pydata([(-2, 0, -1), (2, 0, -1), (2, 0, 1), (-2, 0, 1)], [], [(0, 1, 2, 3)])
material = bpy.data.materials.new("Image")
mesh.materials.append(material)
obj = bpy.data.objects.new("Surface", mesh)
bpy.context.collection.objects.link(obj)
modifier = obj.modifiers.new("Surface", "NODES")
modifier.node_group = group
image = bpy.data.images.new("Camera", width=16, height=8, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
yy, xx = np.mgrid[:8, :16]
# Camera principal point is deliberately off center; Y points down.
pixels = np.ones((8, 16, 4), dtype=np.float32)
pixels[..., 0] = ((xx + 0.5) - 5) / 4
pixels[..., 1] = (3 - (yy + 0.5)) / 4
pixels[..., 2] = 2 + (xx + 0.5) / 16
pixels[..., 3] = 1
image.pixels.foreach_set(pixels.ravel())
inputs = {item.name: item for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
def set_value(name, value):
modifier[inputs[name].identifier] = value
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
for name, value in (("Subdivide", 2), ("Depth Image", image),
("Uniform Scale", 0.5), ("Reference Depth", 1.2),
("Depth Scale", 0.0), ("Thickness", 0.0)):
set_value(name, value)
if "Depth Offset" in inputs:
set_value("Depth Offset", 0.0)
if "Boundary Smooth" in inputs:
set_value("Boundary Smooth", 0)
if "Depth Split" in inputs:
set_value("Depth Split", 0.0)
return obj, set_value, plane, inputs, image
def evaluated(obj):
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
return (
np.array([v.co[:] for v in mesh.vertices]),
[tuple(face.vertices) for face in mesh.polygons],
np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]) if mesh.uv_layers else np.empty((0, 2)),
[material.name if material else None for material in mesh.materials],
[face.material_index for face in mesh.polygons],
[attr.name for attr in mesh.attributes],
)
finally:
result.to_mesh_clear()