Source code for simvx.graphics.assets.image_loader

"""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