Spaces:
Runtime error
Runtime error
File size: 4,610 Bytes
d708e6c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """
image_utils.py
---------------
Shared, low-level image helpers used by every other module.
Hard rule enforced throughout this whole project (see CRITICAL RULES in the
spec): extracted sprite pixels must never be resampled. The only resize
operation allowed anywhere in this codebase is Image.NEAREST, and only for
*preview* purposes -- never for anything that gets written to the dataset.
"""
from __future__ import annotations
import hashlib
import io
from dataclasses import dataclass
import numpy as np
from PIL import Image, ImageFile
# Some scraped sprite sheets are slightly truncated; don't hard-fail on that.
ImageFile.LOAD_TRUNCATED_IMAGES = True
class UnsupportedImageError(Exception):
"""Raised when an uploaded file cannot be safely decoded as an image."""
def load_image_rgba(path_or_bytes) -> Image.Image:
"""
Load an image and normalize it to RGBA mode WITHOUT resampling pixels.
Converting palette/RGB/LA modes to RGBA is a lossless, per-pixel exact
operation (not a resize), so it does not violate pixel preservation.
"""
try:
if isinstance(path_or_bytes, (bytes, bytearray)):
img = Image.open(io.BytesIO(path_or_bytes))
else:
img = Image.open(path_or_bytes)
img.load()
except Exception as exc: # noqa: BLE001 - we want to catch any decode failure
raise UnsupportedImageError(f"Could not decode image: {exc}") from exc
if img.mode != "RGBA":
img = img.convert("RGBA")
return img
def to_numpy(img: Image.Image) -> np.ndarray:
"""RGBA PIL Image -> HxWx4 uint8 numpy array."""
return np.array(img, dtype=np.uint8)
def to_pil(arr: np.ndarray) -> Image.Image:
"""HxWx4 uint8 numpy array -> RGBA PIL Image."""
return Image.fromarray(arr.astype(np.uint8), mode="RGBA")
def crop_exact(arr: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
"""
Pixel-exact crop, no resampling. Bounds are clamped to the array so a
slightly-off manual edit never raises.
"""
height, width = arr.shape[:2]
x0 = max(0, x)
y0 = max(0, y)
x1 = min(width, x + w)
y1 = min(height, y + h)
if x1 <= x0 or y1 <= y0:
return arr[0:0, 0:0]
return arr[y0:y1, x0:x1].copy()
def pad_nearest(arr: np.ndarray, padding: int) -> np.ndarray:
"""
Add `padding` pixels of border around a sprite using nearest-pixel
replication (edge padding), never interpolation. padding=0 returns the
array unchanged.
"""
if padding <= 0:
return arr
return np.pad(
arr,
pad_width=((padding, padding), (padding, padding), (0, 0)),
mode="edge",
)
def nearest_preview(img: Image.Image, target_long_side: int = 512) -> Image.Image:
"""
Upscale a (usually tiny) sprite for on-screen preview using NEAREST only.
This output must NEVER be written to the dataset -- preview only.
"""
w, h = img.size
if w == 0 or h == 0:
return img
scale = max(1, int(target_long_side / max(w, h)))
new_size = (w * scale, h * scale)
return img.resize(new_size, resample=Image.NEAREST)
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def image_to_png_bytes(img: Image.Image) -> bytes:
"""Serialize losslessly to PNG (PNG is lossless, so this is safe)."""
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=False)
return buf.getvalue()
@dataclass
class BBox:
x: int
y: int
width: int
height: int
@property
def x2(self) -> int:
return self.x + self.width
@property
def y2(self) -> int:
return self.y + self.height
@property
def area(self) -> int:
return self.width * self.height
@property
def cx(self) -> float:
return self.x + self.width / 2.0
@property
def cy(self) -> float:
return self.y + self.height / 2.0
def gap_to(self, other: "BBox") -> tuple[float, float]:
"""
Signed horizontal/vertical gap between two bounding boxes.
Negative values mean the boxes overlap on that axis.
"""
dx = max(other.x - self.x2, self.x - other.x2)
dy = max(other.y - self.y2, self.y - other.y2)
return dx, dy
def to_dict(self) -> dict:
return {"x": self.x, "y": self.y, "width": self.width, "height": self.height}
def union(self, other: "BBox") -> "BBox":
x0 = min(self.x, other.x)
y0 = min(self.y, other.y)
x1 = max(self.x2, other.x2)
y1 = max(self.y2, other.y2)
return BBox(x0, y0, x1 - x0, y1 - y0)
|