Source code for simvx.graphics.assets.image_loader

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