OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge / test / test_tile_baker.py

355 lines · python ref:
#!/usr/bin/env python3
"""Tests for tile_baker.py — standalone, no Blender required.

Run: python3 test/test_tile_baker.py
"""

import importlib.util
import os
import sys
import struct

import numpy as np


# Load shm_protocol directly (avoid bpy import from __init__.py)
_addon_dir = os.path.join(os.path.dirname(__file__), "..", "blender_addon")

_shm_spec = importlib.util.spec_from_file_location(
    "blender_addon.shm_protocol",
    os.path.join(_addon_dir, "shm_protocol.py"),
    submodule_search_locations=[],
)
shm_protocol = importlib.util.module_from_spec(_shm_spec)
sys.modules["blender_addon"] = type(sys)("blender_addon")
sys.modules["blender_addon"].__path__ = [_addon_dir]
sys.modules["blender_addon.shm_protocol"] = shm_protocol
_shm_spec.loader.exec_module(shm_protocol)

# Now we can import tile_baker (it imports from .shm_protocol via relative import)
_tb_spec = importlib.util.spec_from_file_location(
    "blender_addon.tile_baker",
    os.path.join(_addon_dir, "tile_baker.py"),
    submodule_search_locations=[],
)
tile_baker = importlib.util.module_from_spec(_tb_spec)
tile_baker.__package__ = "blender_addon"
sys.modules["blender_addon.tile_baker"] = tile_baker
_tb_spec.loader.exec_module(tile_baker)

ShmRegion = shm_protocol.ShmRegion
TileBaker = tile_baker.TileBaker

SHM_NAME = "/test_tile_baker"
SHM_SIZE = 4 * 1024 * 1024  # 4 MB
TILE_SIZE = 128
TILE_BYTES = TILE_SIZE * TILE_SIZE * 4  # RGBA8

passed = 0
failed = 0


def check(name: str, condition: bool, detail: str = ""):
    global passed, failed
    if condition:
        print(f"  {name:<50s} PASS")
        passed += 1
    else:
        print(f"  {name:<50s} FAIL  {detail}")
        failed += 1


def make_shm():
    shm = ShmRegion()
    assert shm.create(SHM_NAME, SHM_SIZE), "Failed to create SHM"
    return shm


def make_baker(shm):
    baker = TileBaker(shm, tile_size=TILE_SIZE, max_mips=4)
    ring_base = SHM_SIZE // 2
    ring_size = SHM_SIZE // 2
    baker.init_ring_buffer(ring_base, ring_size)
    return baker


def make_test_image(w, h):
    """Create a gradient test image (H, W, 4) float32."""
    img = np.zeros((h, w, 4), dtype=np.float32)
    for y in range(h):
        for x in range(w):
            img[y, x, 0] = x / w  # R = horizontal gradient
            img[y, x, 1] = y / h  # G = vertical gradient
            img[y, x, 2] = 0.5    # B = constant
            img[y, x, 3] = 1.0    # A = opaque
    return img


# ── Test 1: Ring buffer write ────────────────────────────────────────────────

print("=== Tile Baker Tests ===")

shm = make_shm()
try:
    baker = make_baker(shm)

    # Write some data to the ring
    test_data = b"\xAA" * 256
    offset = baker._write_to_ring(test_data)
    check("ring_buffer_write", offset == SHM_SIZE // 2)

    # Read it back
    raw = bytes(shm._mm[offset : offset + 256])
    check("ring_buffer_readback", raw == test_data)

    # Second write should be sequential
    offset2 = baker._write_to_ring(test_data)
    check("ring_buffer_sequential", offset2 == SHM_SIZE // 2 + 256)

finally:
    shm.close()


# ── Test 2: Ring buffer wrap-around ──────────────────────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)
    ring_size = SHM_SIZE // 2

    # Fill most of the ring
    big_data = b"\xBB" * (ring_size - 100)
    offset = baker._write_to_ring(big_data)
    check("ring_fill", offset >= 0)

    # Next write won't fit — should wrap
    wrap_data = b"\xCC" * 200
    offset2 = baker._write_to_ring(wrap_data)
    check("ring_wrap_around", offset2 == SHM_SIZE // 2, f"got {offset2}")

finally:
    shm.close()


# ── Test 3: Extract single tile from image ───────────────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)

    # Create a 256x256 image (2x2 tiles at 128px)
    img = make_test_image(256, 256)

    # Extract tile (0,0)
    tile_data = baker._extract_tile(img, 256, 256, 0, 0, TILE_SIZE)
    check("extract_tile_size", len(tile_data) == TILE_BYTES, f"got {len(tile_data)}")

    # First pixel should be near (0, 0, 128, 255) for the gradient
    r, g, b, a = tile_data[0], tile_data[1], tile_data[2], tile_data[3]
    check("extract_tile_first_pixel", r == 0 and g == 0 and b == 128 and a == 255,
          f"got ({r},{g},{b},{a})")

    # Extract tile (1,1) — bottom-right quadrant
    tile_data_11 = baker._extract_tile(img, 256, 256, 1, 1, TILE_SIZE)
    # Last pixel of tile (1,1) = image pixel (255, 255), which is ~(1.0, 1.0, 0.5, 1.0)
    last = TILE_BYTES - 4
    r2, g2, b2, a2 = tile_data_11[last], tile_data_11[last + 1], tile_data_11[last + 2], tile_data_11[last + 3]
    check("extract_tile_last_pixel", r2 >= 254 and g2 >= 254 and b2 == 128 and a2 == 255,
          f"got ({r2},{g2},{b2},{a2})")

finally:
    shm.close()


# ── Test 4: Edge tile padding ────────────────────────────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)

    # Create a 200x150 image (not a multiple of 128)
    img = make_test_image(200, 150)

    # Tile (1,1) covers pixels (128..199, 128..149) — only 72x22, padded to 128x128
    tile_data = baker._extract_tile(img, 200, 150, 1, 1, TILE_SIZE)
    check("edge_tile_padded_size", len(tile_data) == TILE_BYTES)

    # Pixel at (72, 0) in the tile should be zero (padding)
    idx = (0 * TILE_SIZE + 72) * 4  # row 0, col 72
    r = tile_data[idx]
    check("edge_tile_padding_zero", r == 0, f"got r={r}")

finally:
    shm.close()


# ── Test 5: Bake tiles in dirty rect ────────────────────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)
    baker.register_texture("test_image", 1)

    # Create 512x512 image (4x4 tiles)
    img = make_test_image(512, 512)

    # Dirty rect covering tiles (1,1) and (2,1) — x=128..383, y=128..255
    baker.bake_from_array(img, 512, 512, 1, 128, 128, 256, 128)

    messages = baker.get_pending_messages()

    # Should have mip 0 tiles + mip parents
    mip0_msgs = [m for m in messages if m.endswith(",0,") is False and ",0," in m.split(":")[-1].split(",")[3] == "0"]
    # Simpler: count mip 0 messages
    mip0_count = sum(1 for m in messages if m.split(",")[3] == "0")
    check("bake_dirty_rect_mip0_count", mip0_count == 2, f"got {mip0_count}")

    # Verify message format: tile_ready:tex_id,tx,ty,mip,offset,size
    first = messages[0]
    check("message_format", first.startswith("tile_ready:1,"), f"got: {first}")

    # All mip 0 tiles should have correct size
    for msg in messages:
        parts = msg.split(":")[1].split(",")
        if parts[3] == "0":
            size = int(parts[5])
            check(f"tile_size_mip0_({parts[1]},{parts[2]})", size == TILE_BYTES,
                  f"got {size}")

finally:
    shm.close()


# ── Test 6: Mip generation ──────────────────────────────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)
    baker.register_texture("test_image", 1)

    # Create 256x256 image (2x2 tiles) and bake all
    img = make_test_image(256, 256)
    baker.bake_from_array(img, 256, 256, 1, 0, 0, 256, 256)

    messages = baker.get_pending_messages()
    mip_counts = {}
    for msg in messages:
        parts = msg.split(":")[1].split(",")
        mip = int(parts[3])
        mip_counts[mip] = mip_counts.get(mip, 0) + 1

    # Mip 0: 4 tiles (2x2), Mip 1: 1 tile (1x1)
    check("mip0_tile_count", mip_counts.get(0, 0) == 4, f"got {mip_counts.get(0, 0)}")
    check("mip1_tile_count", mip_counts.get(1, 0) == 1, f"got {mip_counts.get(1, 0)}")

    # Verify mip 1 tile is at position (0,0)
    mip1_msgs = [m for m in messages if m.split(":")[1].split(",")[3] == "1"]
    if mip1_msgs:
        parts = mip1_msgs[0].split(":")[1].split(",")
        check("mip1_position", parts[1] == "0" and parts[2] == "0",
              f"got ({parts[1]},{parts[2]})")

finally:
    shm.close()


# ── Test 7: Mip tile pixel averaging ────────────────────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)

    # Create a uniform red 256x256 image
    img = np.zeros((256, 256, 4), dtype=np.float32)
    img[:, :, 0] = 1.0  # R
    img[:, :, 3] = 1.0  # A

    baker.bake_from_array(img, 256, 256, 1, 0, 0, 256, 256)
    messages = baker.get_pending_messages()

    # Find the mip 1 tile and read it back from SHM
    mip1_msgs = [m for m in messages if m.split(":")[1].split(",")[3] == "1"]
    check("mip1_exists", len(mip1_msgs) > 0)

    if mip1_msgs:
        parts = mip1_msgs[0].split(":")[1].split(",")
        offset = int(parts[4])
        size = int(parts[5])

        # Read tile from SHM
        tile_bytes = bytes(shm._mm[offset : offset + size])
        tile = np.frombuffer(tile_bytes, dtype=np.uint8).reshape(TILE_SIZE, TILE_SIZE, 4)

        # Should be solid red (255, 0, 0, 255) since all children are red
        check("mip1_red_channel", tile[0, 0, 0] == 255, f"got {tile[0, 0, 0]}")
        check("mip1_green_channel", tile[0, 0, 1] == 0, f"got {tile[0, 0, 1]}")
        check("mip1_alpha_channel", tile[0, 0, 3] == 255, f"got {tile[0, 0, 3]}")

finally:
    shm.close()


# ── Test 8: No messages for unregistered textures ───────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)
    # Don't register any texture

    img = make_test_image(256, 256)
    baker.bake_from_array(img, 256, 256, 99, 0, 0, 256, 256)

    # bake_from_array takes tex_id directly, so this still works
    messages = baker.get_pending_messages()
    check("direct_bake_works", len(messages) > 0)

    # But bake_dirty_region without registration should produce nothing
    # (can't test without bpy, but verify the lookup logic)
    check("unregistered_lookup", baker._texture_ids.get("nonexistent") is None)

finally:
    shm.close()


# ── Test 9: SHM ring buffer data integrity ──────────────────────────────────

shm = make_shm()
try:
    baker = make_baker(shm)

    # Create a small checkerboard pattern
    img = np.zeros((128, 128, 4), dtype=np.float32)
    img[::2, ::2] = [1.0, 1.0, 1.0, 1.0]  # White pixels at even coords
    img[1::2, 1::2] = [1.0, 1.0, 1.0, 1.0]  # White pixels at odd coords

    baker.bake_from_array(img, 128, 128, 1, 0, 0, 128, 128)
    messages = baker.get_pending_messages()

    # Read the mip 0 tile from SHM and verify pattern
    mip0_msgs = [m for m in messages if m.split(":")[1].split(",")[3] == "0"]
    if mip0_msgs:
        parts = mip0_msgs[0].split(":")[1].split(",")
        offset = int(parts[4])
        size = int(parts[5])

        tile_bytes = bytes(shm._mm[offset : offset + size])
        tile = np.frombuffer(tile_bytes, dtype=np.uint8).reshape(TILE_SIZE, TILE_SIZE, 4)

        # Pixel (0,0) = white, pixel (0,1) = black, pixel (1,0) = black, pixel (1,1) = white
        check("checkerboard_0_0", tile[0, 0, 0] == 255, f"got {tile[0, 0, 0]}")
        check("checkerboard_0_1", tile[0, 1, 0] == 0, f"got {tile[0, 1, 0]}")
        check("checkerboard_1_1", tile[1, 1, 0] == 255, f"got {tile[1, 1, 0]}")

finally:
    shm.close()


# ── Summary ──────────────────────────────────────────────────────────────────

print()
if failed == 0:
    print(f"All {passed} tests passed.")
else:
    print(f"{passed} passed, {failed} FAILED.")
    sys.exit(1)