"""Image file I/O for textures — loading and saving PNG/JPG."""
import logging
import struct
import zlib
from pathlib import Path
from typing import Any
import numpy as np
import vulkan as vk
from PIL import Image
from simvx.graphics.gpu.memory import upload_image_data
log = logging.getLogger(__name__)
[docs]
def load_texture_from_file(
device: Any,
physical_device: Any,
queue: Any,
cmd_pool: Any,
file_path: str,
) -> tuple[Any, Any, int, int]:
"""Load PNG/JPG texture from disk → device-local VkImage.
Returns: (image, memory, width, height)
"""
img = Image.open(file_path).convert("RGBA")
width, height = img.size
pixels = np.ascontiguousarray(np.array(img, dtype=np.uint8))
image, memory = upload_image_data(
device,
physical_device,
queue,
cmd_pool,
pixels,
width,
height,
vk.VK_FORMAT_R8G8B8A8_UNORM,
)
return image, memory, width, height
# ---------------------------------------------------------------------------
# PNG I/O (pure Python, no Pillow)
# ---------------------------------------------------------------------------
def _png_chunk(chunk_type: bytes, data: bytes) -> bytes:
"""Build a single PNG chunk: length + type + data + CRC."""
crc = zlib.crc32(chunk_type + data) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + chunk_type + data + struct.pack(">I", crc)
[docs]
def save_png(path: str | Path, pixels: np.ndarray) -> None:
"""Save RGBA uint8 pixels (H, W, 4) as a PNG file. Pure Python, no Pillow."""
h, w = pixels.shape[:2]
channels = pixels.shape[2] if pixels.ndim == 3 else 1
if channels not in (3, 4):
raise ValueError(f"Expected 3 or 4 channels, got {channels}")
colour_type = 6 if channels == 4 else 2 # RGBA or RGB
raw = bytearray()
row_bytes = pixels[:, :, :channels].reshape(h, -1)
for y in range(h):
raw.append(0) # filter type: None
raw.extend(row_bytes[y].tobytes())
ihdr = struct.pack(">IIBBBBB", w, h, 8, colour_type, 0, 0, 0)
compressed = zlib.compress(bytes(raw), 9)
p = Path(path)
with open(p, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
f.write(_png_chunk(b"IHDR", ihdr))
f.write(_png_chunk(b"IDAT", compressed))
f.write(_png_chunk(b"IEND", b""))
def _load_png(path: str | Path) -> np.ndarray:
"""Load a PNG written by save_png() back to an RGBA (H, W, 4) uint8 ndarray."""
data = Path(path).read_bytes()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError(f"Not a PNG file: {path}")
pos = 8
width = height = 0
channels = 4
idat_parts: list[bytes] = []
while pos < len(data):
length = struct.unpack(">I", data[pos : pos + 4])[0]
chunk_type = data[pos + 4 : pos + 8]
chunk_data = data[pos + 8 : pos + 8 + length]
pos += 12 + length
if chunk_type == b"IHDR":
width, height, bit_depth, colour_type = struct.unpack(">IIBB", chunk_data[:10])
if bit_depth != 8:
raise ValueError(f"Unsupported bit depth: {bit_depth}")
channels = 4 if colour_type == 6 else 3
elif chunk_type == b"IDAT":
idat_parts.append(chunk_data)
elif chunk_type == b"IEND":
break
raw = zlib.decompress(b"".join(idat_parts))
stride = 1 + width * channels
pixels = np.empty((height, width, channels), dtype=np.uint8)
for y in range(height):
row_start = y * stride
if raw[row_start] != 0:
raise ValueError(f"Unsupported PNG filter type {raw[row_start]} at row {y}")
row_data = raw[row_start + 1 : row_start + 1 + width * channels]
pixels[y] = np.frombuffer(row_data, dtype=np.uint8).reshape(width, channels)
if channels == 3:
alpha = np.full((height, width, 1), 255, dtype=np.uint8)
pixels = np.concatenate([pixels, alpha], axis=2)
return pixels