"""Image file I/O for textures — loading and saving PNG/JPG."""
from __future__ import annotations
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