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)