Files
AnyImage/tests/test_image_data.py
nan 7e55af6722 Add comprehensive tests for edge depth extension, empty image edits, and material view adaptation
- Introduced `test_edge_depth.py` to validate the functionality of edge depth extension, ensuring core alpha preservation, proper depth handling, and edge padding behavior.
- Created `test_empty_image_edits.py` to verify the behavior of image edits in Blender, focusing on shared edits, preservation of image properties, and handling of failed operations.
- Developed `test_material_view_adaptation.py` to test material adaptation preferences, ensuring color space and alpha settings are correctly applied and preserved across various scenarios.
2026-09-06 21:43:47 +08:00

570 lines
18 KiB
Python

import ast
import importlib
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
sys.path.insert(0, str(SOURCE_ROOT))
COMMON_ROOT = SOURCE_ROOT / "anyimage" / "common"
COMMON_MODULES = {
"ai",
"depth",
"image",
"material",
"node",
"object",
"selection",
"viewport",
}
def test_common_modules_follow_business_boundaries():
module_names = {
path.stem
for path in COMMON_ROOT.glob("*.py")
if path.name != "__init__.py"
}
assert module_names == COMMON_MODULES
for module_name in COMMON_MODULES:
importlib.import_module(f"anyimage.common.{module_name}")
ai_ui_definitions = {
path.name
for path in COMMON_ROOT.glob("*.py")
if "def draw_ai_setup(" in path.read_text(encoding="utf-8")
or "def draw_ai_property(" in path.read_text(encoding="utf-8")
}
assert ai_ui_definitions == {"ai.py"}
init_tree = ast.parse(
(COMMON_ROOT / "__init__.py").read_text(encoding="utf-8")
)
assert not any(
isinstance(node, (ast.Import, ast.ImportFrom))
for node in init_tree.body
)
def load_image_modules():
fake_bpy = ModuleType("bpy")
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
IntProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(Menu=object, Operator=object)
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
fake_bpy.context = SimpleNamespace(scene=None)
module_paths = (
(
"anyimage.common.image",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "image.py",
),
(
"anyimage.common.node",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "node.py",
),
(
"anyimage.common.material",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "material.py",
),
(
"anyimage.common.object",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "object.py",
),
)
packages = {
name: ModuleType(name)
for name in (
"anyimage",
"anyimage.operators",
"anyimage.common",
"anyimage.operators.convert_to_plane",
)
}
for package in packages.values():
package.__path__ = []
loaded = []
preferences = ModuleType("anyimage.preferences")
preferences.configured_material_view_adaptation = lambda: True
with patch.dict(sys.modules, {"bpy": fake_bpy, "anyimage.preferences": preferences, **packages}):
for module_name, module_path in module_paths:
specification = importlib.util.spec_from_file_location(
module_name,
module_path,
)
module = importlib.util.module_from_spec(specification)
sys.modules[module_name] = module
specification.loader.exec_module(module)
loaded.append(module)
namespace = SimpleNamespace()
for module in loaded:
for name, value in vars(module).items():
if not name.startswith("__"):
setattr(namespace, name, value)
return namespace
class ImageDataTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.image_depth = load_image_modules()
def test_node_asset_path_survives_package_split(self):
self.assertEqual(
self.image_depth.node_asset_path(),
PROJECT_ROOT / "src" / "anyimage" / "assets" / "O_AnyImage.blend",
)
def test_runtime_modifier_enum_input_uses_string_identifier(self):
enum_items = (
SimpleNamespace(identifier="SURFACE"),
SimpleNamespace(identifier="BALLOON"),
)
value_property = SimpleNamespace(type="ENUM", enum_items=enum_items)
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Shape=input_slot),
)
)
self.image_depth.set_modifier_input(modifier, "Shape", 1)
self.assertEqual(input_slot.value, "BALLOON")
def test_runtime_modifier_non_enum_and_legacy_inputs_keep_values(self):
value_property = SimpleNamespace(type="FLOAT")
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
runtime_modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Thickness=input_slot),
)
)
self.image_depth.set_modifier_input(runtime_modifier, "Thickness", 2.0)
self.assertEqual(input_slot.value, 2.0)
class LegacyModifier(dict):
properties = None
legacy_modifier = LegacyModifier()
self.image_depth.set_modifier_input(legacy_modifier, "Shape", 1)
self.assertEqual(legacy_modifier["Shape"], 1)
def test_material_color_space_matches_scene_view_transform(self):
mappings = {
"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",
"Standard": "sRGB",
}
for view_transform, expected in mappings.items():
with self.subTest(view_transform=view_transform):
scene = SimpleNamespace(
view_settings=SimpleNamespace(
view_transform=view_transform,
)
)
self.assertEqual(
self.image_depth.material_color_space_name(scene),
expected,
)
def test_material_color_space_falls_back_when_unsupported(self):
class ColorSpaceSettings:
def __init__(self):
self._name = "sRGB"
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if value != "sRGB":
raise TypeError("unsupported color space")
self._name = value
image = SimpleNamespace(
colorspace_settings=ColorSpaceSettings(),
users=0, use_fake_user=False, alpha_mode="STRAIGHT",
)
scene = SimpleNamespace(
view_settings=SimpleNamespace(view_transform="ACES 2.0")
)
result = self.image_depth.configure_material_color_image(image, scene)
self.assertIs(result, image)
self.assertEqual(image.alpha_mode, "PREMUL")
self.assertEqual(image.colorspace_settings.name, "sRGB")
def test_is_animated_image_accepts_movie_and_sequence(self):
self.assertTrue(
self.image_depth.is_animated_image(SimpleNamespace(source="MOVIE"))
)
self.assertTrue(
self.image_depth.is_animated_image(SimpleNamespace(source="SEQUENCE"))
)
self.assertFalse(
self.image_depth.is_animated_image(SimpleNamespace(source="FILE"))
)
def test_video_total_frames_reads_movie_duration(self):
image = SimpleNamespace(source="MOVIE", frame_duration=97)
self.assertEqual(self.image_depth.video_total_frames(image), 97)
def test_video_total_frames_counts_sequence_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "foreground-0001.png").touch()
(root / "foreground-0002.png").touch()
image = SimpleNamespace(
source="SEQUENCE",
filepath=str(root / "foreground-0001.png"),
)
self.assertEqual(self.image_depth.video_total_frames(image), 2)
def test_video_total_frames_returns_one_for_still_image(self):
image = SimpleNamespace(source="FILE", frame_duration=1)
self.assertEqual(self.image_depth.video_total_frames(image), 1)
def test_default_extract_frames_uses_empty_image_frames(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(frame_duration=24),
)
self.assertEqual(self.image_depth.default_extract_frames(source_object), 24)
def test_default_extract_frames_caps_at_video_total(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(frame_duration=200),
)
self.assertEqual(self.image_depth.default_extract_frames(source_object), 97)
def test_default_extract_frames_falls_back_to_video_total(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
)
self.assertEqual(self.image_depth.default_extract_frames(source_object), 97)
def test_extract_frame_range_honors_offset_within_clip(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=4,
frame_offset=70,
frame_duration=24,
),
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
(71, 24),
)
def test_extract_frame_range_clamps_large_offset_to_clip_tail(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=90,
frame_duration=24,
),
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
(74, 24),
)
def test_extract_frame_range_clamps_negative_offset_to_start(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=-5,
frame_duration=24,
),
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
(1, 24),
)
def test_extract_frame_range_returns_full_clip_for_still(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="FILE", frame_duration=1),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=50,
frame_duration=24,
),
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
(1, 1),
)
def test_extract_frame_range_honors_single_frame_setting(self):
source_object = SimpleNamespace(
data=SimpleNamespace(source="MOVIE", frame_duration=97),
image_user=SimpleNamespace(
frame_start=1,
frame_offset=70,
frame_duration=1,
),
)
self.assertEqual(
self.image_depth.extract_frame_range(source_object),
(71, 1),
)
def test_configure_image_user_preserves_cyclic_setting(self):
image_user = SimpleNamespace(
frame_start=0,
frame_offset=5,
use_auto_refresh=False,
use_cyclic=False,
frame_duration=1,
)
self.image_depth.configure_image_user(
image_user,
97,
frame_start=4,
frame_offset=70,
)
self.assertFalse(image_user.use_cyclic)
self.assertTrue(image_user.use_auto_refresh)
self.assertEqual(image_user.frame_start, 4)
self.assertEqual(image_user.frame_offset, 70)
self.assertEqual(image_user.frame_duration, 97)
self.image_depth.configure_image_user(
image_user,
use_cyclic=True,
)
self.assertTrue(image_user.use_cyclic)
def test_image_user_settings_reads_empty_values(self):
source_object = SimpleNamespace(
image_user=SimpleNamespace(
frame_start=4,
frame_offset=70,
frame_duration=12,
)
)
self.assertEqual(
self.image_depth.image_user_settings(source_object),
(4, 70, 12, False),
)
source_object.image_user.use_cyclic = True
self.assertEqual(
self.image_depth.image_user_settings(source_object),
(4, 70, 12, True),
)
def test_image_user_settings_defaults_without_user(self):
self.assertEqual(
self.image_depth.image_user_settings(SimpleNamespace(image_user=None)),
(1, 0, 0, False),
)
def test_image_base_name_strips_video_extension(self):
image = SimpleNamespace(name="微信视频2026-07-23_192756_414.mp4")
self.assertEqual(
self.image_depth.image_base_name(image),
"微信视频2026-07-23_192756_414",
)
def test_color_name_strips_video_extension(self):
source_object = SimpleNamespace(data=SimpleNamespace(name="clip.mp4"))
self.assertEqual(
self.image_depth.color_data_name(source_object),
"clip_color.png",
)
def test_force_sequence_source_keeps_loaded_sequence(self):
image = SimpleNamespace(source="SEQUENCE")
result = self.image_depth.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_switches_single_image(self):
image = SimpleNamespace(source="FILE")
result = self.image_depth.force_sequence_source(image)
self.assertIs(result, image)
self.assertEqual(image.source, "SEQUENCE")
def test_force_sequence_source_reports_unloadable_sequence(self):
class BlockedImage:
@property
def source(self):
return "FILE"
@source.setter
def source(self, value):
raise RuntimeError("blocked")
with self.assertRaisesRegex(RuntimeError, "cannot load"):
self.image_depth.force_sequence_source(BlockedImage())
def test_collect_sequence_frames_returns_numbered_files_in_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "frame-0001.jpg").touch()
(root / "frame-0002.jpg").touch()
(root / "frame-0003.jpg").touch()
(root / "notes.txt").touch()
image = SimpleNamespace(
source="SEQUENCE",
filepath=str(root / "frame-0002.jpg"),
)
frames = self.image_depth.collect_sequence_frames(image)
self.assertEqual(
[path.name for path in frames],
["frame-0001.jpg", "frame-0002.jpg", "frame-0003.jpg"],
)
def test_sorted_frame_paths_uses_numeric_frame_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "frame-2.jpg").touch()
(root / "frame-10.jpg").touch()
(root / "frame-1.jpg").touch()
frames = self.image_depth.sorted_frame_paths(root)
self.assertEqual(
[path.name for path in frames],
["frame-1.jpg", "frame-2.jpg", "frame-10.jpg"],
)
def test_depth_data_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.image_depth.depth_data_name(source_object),
"img_depth.exr",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.image_depth.depth_data_name(source_object),
"img.001_depth.exr",
)
def test_moge_normal_name_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.image_depth.normal_data_name(source_object),
"img_normal.png",
)
def test_color_name_uses_source_image_base_name(self):
source_object = SimpleNamespace(
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.image_depth.color_data_name(source_object),
"img_color.png",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.image_depth.color_data_name(source_object),
"img.001_color.png",
)
def test_surface_displacement_is_set_to_displacement_only(self):
class Material:
surface_displacement_method = "BUMP"
material = Material()
self.image_depth.set_displacement_only(material)
self.assertEqual(
material.surface_displacement_method,
"DISPLACEMENT",
)
def test_image_empty_bounds_keep_image_aspect_and_empty_offset(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(800, 400)),
empty_display_size=2.0,
empty_image_offset=(-0.5, -0.5),
)
bounds = self.image_depth.image_empty_bounds(source)
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))
if __name__ == "__main__":
unittest.main()