-
Notifications
You must be signed in to change notification settings - Fork 0
/
AEI.py
464 lines (344 loc) · 17.7 KB
/
AEI.py
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
import io
from typing import Any, BinaryIO
from os import PathLike
from types import TracebackType
from typing import Any, List, Optional, Set, Tuple, Type, TypeVar, Union, cast, overload
from PIL import Image
from ..lib import imageOps
from ..lib.binaryio import uint8, uint16, uint32, readUInt8, readUInt16, readUInt32
from ..constants import CompressionFormat, FILE_TYPE_HEADER, ENDIANNESS, CompressionQuality
from .. import codec
from .texture import Texture
from ..exceptions import UnsupportedAeiFeatureException, AeiReadException, AeiWriteException
TException = TypeVar("TException", bound=Exception)
class AEI:
"""An Abyss Engine Image file.
Contains a set of textures, each with an image and coordinates.
Each texture must fall within the bounds of the AEI shape.
The AEI shape is mutable, through the `shape` property.
The coordinate origin (0, 0) is the top-left of the AEI.
An AEI can be constructed either with its dimensions, or with an image.
If an image is used, the AEI is created with a copy of the image.
`format` and `quality` can be set in the constructor, or on call of `AEI.write`.
Use the `addTexture` and `removeTexture` helper methods for texture management.
To decode an existing AEI file, use `AEI.read`.
To encode an AEI into a file, use `AEI.write`.
If the AEI is scoped in a `with` statement, when exiting the `with`,
the AEI will attempt to close all images, and swallow any errors encountered.
"""
@overload
def __init__(self, shape: Tuple[int, int], /, format: Optional[CompressionFormat] = None, quality: Optional[CompressionQuality] = None) -> None: ...
@overload
def __init__(self, image: Image.Image, /, format: Optional[CompressionFormat] = None, quality: Optional[CompressionQuality] = None) -> None: ...
def __init__(self, val1: Union[Image.Image, Tuple[int, int]], /, format: Optional[CompressionFormat] = None, quality: Optional[CompressionQuality] = None):
self._textures: List[Texture] = []
self._texturesWithoutImages: Set[Texture] = set()
self.format = format
self.quality: Optional[CompressionQuality] = quality
if isinstance(val1, Image.Image):
self._shape = val1.size
self._image = val1.copy()
else:
self._shape = val1
self._image = Image.new("RGBA", self._shape)
@property
def shape(self):
"""The dimensions of the AEI, in pixels.
:return: (width, height)
:rtype: Tuple[int, int]
"""
return self._shape
@shape.setter
def shape(self, value: Tuple[int, int]):
widthShrunk = value[0] < self.shape[0]
heightShrunk = value[1] < self.shape[1]
if widthShrunk or heightShrunk:
for tex in self.textures:
if widthShrunk and tex.x + tex.width > value[0] \
or heightShrunk and tex.y + tex.height > value[1]:
raise ValueError(f"Changing shape from ({self.shape[0]}, {self.shape[1]}) to ({value[0]}, {value[1]}) would cause texture ({tex.x}, {tex.y}) to fall out of bounds")
self._shape = value
@property
def width(self):
return self.shape[0]
@width.setter
def width(self, value: int):
self.shape = (value, self.height)
@property
def height(self):
return self.shape[1]
@height.setter
def height(self, value: int):
self.shape = (self.width, value)
@property
def textures(self):
"""Do not use this property to manage textures. Instead use `addTexture` and `removeTexture`.
This is the mutable, internal representation. Altering directly could cause issues.
:return: The textures within the AEI
:rtype: List[Texture]
"""
return self._textures
def _validateBoundingBox(self, val1: Union[Texture, int], y: Optional[int] = None, width: Optional[int] = None, height: Optional[int] = None) -> Tuple[int, int, int, int]:
if isinstance(val1, Texture):
y = val1.y
width = val1.width
height = val1.height
val1 = val1.x
elif y is None or width is None or height is None:
raise ValueError("All of x, y, width and height are required")
if val1 < 0 or width < 1 or y < 0 or height < 1 or val1 + width > self.width or y + height > self.height:
raise ValueError("The bounding box falls out of bounds of the AEI")
return (val1, y, width, height)
def _findTextureByBox(self, val1: Union[Texture, int], y: Optional[int] = None, width: Optional[int] = None, height: Optional[int] = None):
x, y, width, height = self._validateBoundingBox(val1, y, width, height)
try:
texture = next(t for t in self.textures if t.x == x and t.y == y and t.width == width and t.height == height)
except StopIteration:
return None
return texture
@overload
def addTexture(self, texture: Texture, /) -> None:
"""Add only a texture bounding box to this AEI. Use the overload with `Image` as the first parameter to add image content.
This overload is only really useful for creating overlapping textures.
:param texture: The new texture
:type texture: Texture
:raises ValueError: If the `texture` falls out of bounds of the AEI
"""
@overload
def addTexture(self, x: int, y: int, width: int, height: int, /) -> None:
"""Add only a texture bounding box to this AEI. Use the overload with `Image` as the first parameter to add image content.
This overload is only really useful for creating overlapping textures.
:param int x: The x-coordinate of the texture
:param int y: The y-coordinate of the texture
:param int width: The width of the texture
:param int height: The height of the texture
:raises ValueError: If the the bounding box falls out of bounds of the AEI
"""
@overload
def addTexture(self, image: Image.Image, x: int, y: int, /) -> None:
"""Add a texture with image content to this AEI.
`image` is not retained, and can be closed after passing to this method without side effects.
:param image: The new image
:type image: Image.Image
:param int x: The x-coordinate of the texture
:param int y: The y-coordinate of the texture
:raises ValueError: If `image.mode` is not `RGBA`
:raises ValueError: If the bounding box falls out of bounds of the AEI
"""
def addTexture(self, val1: Union[Image.Image, Texture, int], val2: Optional[int] = None, val3: Optional[int] = None, val4: Optional[int] = None, /):
if isinstance(val1, Texture):
image = None
texture = val1
elif isinstance(val1, int):
if val2 is None or val3 is None or val4 is None:
raise ValueError("All of x, y, width and height are required")
image = None
texture = Texture(val1, val2, val3, val4)
elif val2 is None or val3 is None:
raise ValueError("Both x and y are required")
else:
image = val1
texture = Texture(val2, val3, image.width, image.height)
existingTexture = self._findTextureByBox(texture)
if existingTexture is not None:
raise ValueError("A texture already exists with the given bounding box")
if image is None:
self._texturesWithoutImages.add(texture)
else:
if texture.width != image.width or texture.height != image.height:
raise ValueError("image dimensions do not match the texture dimensions")
if image.mode != "RGBA":
raise ValueError(f"image must be mode RGBA, but {image.mode} was given")
self._image.paste(image, (texture.x, texture.y), image)
self.textures.append(texture)
def replaceTexture(self, image: Image.Image, texture: Texture):
"""Replace a texture in this AEI.
`image` is not retained, and can be closed after passing to this method without side effects.
:param image: The new image
:type image: Image.Image
:param texture: The new texture
:type texture: Texture
:raises ValueError: If `image.mode` is not `RGBA`
:raises ValueError: If the `texture` falls out of bounds of the AEI
:raises ValueError: If the dimensions in `texture` do not match the dimensions of `image`
"""
existingTexture = self._findTextureByBox(texture)
if existingTexture is None:
raise KeyError(f"no texture was found with coordinates ({texture.x}, {texture.y}) and dimensions ({texture.width}, {texture.height})")
if texture.width != image.width or texture.height != image.height:
raise ValueError("image dimensions do not match the texture dimensions")
if image.mode != "RGBA":
raise ValueError(f"image must be mode RGBA, but {image.mode} was given")
self._image.paste(image, (texture.x, texture.y), image)
@overload
def removeTexture(self, texture: Texture, /, *, clearImage: Optional[bool] = None) -> None: ...
@overload
def removeTexture(self, x: int, y: int, width: int, height: int, /, *, clearImage: Optional[bool] = None) -> None: ...
def removeTexture(self, val1: Union[Texture, int], y: Optional[int] = None, width: Optional[int] = None, height: Optional[int] = None, /, *, clearImage: Optional[bool] = None):
"""Remove a texture from this AEI, by its bounding box.
:param Optional[bool] clearImage: clear the area of the image. Default: Clear if an image was provided when the texture was added
:raises KeyError: If no corresponding texture could be found for the bounding box
"""
texture = self._findTextureByBox(val1, y, width, height)
if texture is None:
raise KeyError(f"no texture was found with coordinates ({val1}, {y}) and dimensions ({width}, {height})")
if clearImage is not None and clearImage or clearImage is None and texture not in self._texturesWithoutImages:
# Clear the area that the texture occupied
self._image.paste(
(0, 0, 0, 0),
(texture.x, texture.y, texture.x + texture.width, texture.y + texture.height)
)
self._textures.remove(texture)
@overload
def getTexture(self, texture: Texture, /) -> Image.Image: ...
@overload
def getTexture(self, x: int, y: int, width: int, height: int, /) -> Image.Image: ...
def getTexture(self, val1: Union[Texture, int], y: Optional[int] = None, width: Optional[int] = None, height: Optional[int] = None, /) -> Image.Image:
"""Get a copy of the image defined by the provided bounding box.
:returns: a copy of the image defined by the provided bounding box
:rtype: Image.Image
:raises KeyError: The provided bounding box falls out of bounds of the AEI
"""
x, y, width, height = self._validateBoundingBox(val1, y, width, height)
return self._image.crop((x, y, x + width, y + height))
@classmethod
def read(cls, fp: Union[str, PathLike[Any], io.BytesIO]) -> "AEI":
"""Read an AEI file from bytes, or a file.
`fp` can be a path to a file, or an in-memory buffer containing the contents of an encoded AEI file, including metadata.
:param fp: The AEI itself, or a path to an AEI file on disk
:type fp: Union[str, PathLike, io.BytesIO]
:return: A new AEI file object, containing the decoded contents of `fp`
:rtype: AEI
"""
if isinstance(fp, io.StringIO):
raise ValueError("fp must be of binary type, not StringIO")
file: Union[io.BufferedReader, io.BytesIO]
if tempFp := (not isinstance(fp, io.BytesIO)):
file = open(fp, "rb")
elif isinstance(fp, (str, PathLike)):
file = open(fp, mode="rb")
else:
file = fp
try:
bFileType = file.read(len(FILE_TYPE_HEADER))
if bFileType != FILE_TYPE_HEADER:
raise ValueError(f"Given file is of unknown type '{str(bFileType, encoding='utf-8')}' expected '{str(FILE_TYPE_HEADER, encoding='utf-8')}'")
formatId = readUInt8(file, ENDIANNESS)
format, mipmapped = CompressionFormat.fromBinary(formatId)
if mipmapped:
raise UnsupportedAeiFeatureException("Mipmapped textures")
imageCodec = codec.decompressorFor(format)
width = readUInt16(file, ENDIANNESS)
height = readUInt16(file, ENDIANNESS)
numTextures = readUInt16(file, ENDIANNESS)
textures: List[Texture] = []
for _ in range(numTextures):
texX = readUInt16(file, ENDIANNESS)
texY = readUInt16(file, ENDIANNESS)
texWidth = readUInt16(file, ENDIANNESS)
texHeight = readUInt16(file, ENDIANNESS)
textures.append(Texture(texX, texY, texWidth, texHeight))
if format.isCompressed:
imageLength = readUInt32(file, ENDIANNESS)
else:
imageLength = 4 * width * height
compressed = file.read(imageLength)
symbolGroups = readUInt16(file, ENDIANNESS)
if symbolGroups > 0:
raise UnsupportedAeiFeatureException("Symbol maps")
bQuality = readUInt8(file, ENDIANNESS, None)
quality = cast(Optional[CompressionQuality], bQuality)
decompressed = imageCodec.decompress(compressed, format, width, height, quality)
except Exception as ex:
raise AeiReadException() from ex
finally:
if tempFp:
file.close()
aei = AEI(decompressed, format=format, quality=quality)
for tex in textures:
aei.addTexture(tex)
return aei
def write(self, fp: Optional[BinaryIO] = None, format: Optional[CompressionFormat] = None, quality: Optional[CompressionQuality] = None) -> BinaryIO:
"""Write this AEI to a BytesIO file.
:param fp: Optional file to write to. If not given, a new one is created. defaults to None
:type fp: Optional[io.BytesIO], optional
:param format: Override for the compression format. defaults to the setting on the AEI
:type format: Optional[CompressionFormat], optional
:param quality: Override for the compression quality. defaults to the setting on the AEI
:type quality: Optional[CompressionQuality], optional
:raises ValueError: If format is omitted and no format is set on the AEI
:raises ValueError: If the AEI has no textures
:return: A file containing the AEI, including the compressed image and full metadata
:rtype: io.BytesIO
"""
format = format or self.format
quality = self.quality if quality is None else quality
if format is None:
raise ValueError("This AEI has no compression format specified. Set self.format, or specify the format in the self.toFile.format kwarg")
fp = io.BytesIO() if fp is None else fp
# AEIs must contain at least one texture
if tempTexture := (len(self.textures) == 0):
self.addTexture(Texture(0, 0, self.width, self.height))
try:
self._writeHeaderMeta(fp, format)
self._writeImageContent(fp, format, quality)
self._writeSymbols(fp)
self._writeFooterMeta(fp, quality)
except Exception as ex:
raise AeiWriteException() from ex
finally:
if tempTexture:
self.removeTexture(0, 0, self.width, self.height)
return fp
#region write-util
def _writeHeaderMeta(self, fp: BinaryIO, format: CompressionFormat):
fp.write(FILE_TYPE_HEADER)
fp.write(uint8(format.value, ENDIANNESS))
def writeUInt16(*values: int):
for v in values:
fp.write(uint16(v, ENDIANNESS))
# AEI dimensions and texture count
writeUInt16(
self.width,
self.height,
len(self.textures)
)
# texture bounding boxes
for texture in self.textures:
writeUInt16(
texture.x,
texture.y,
texture.width,
texture.height
)
def _writeImageContent(self, fp: BinaryIO, format: CompressionFormat, quality: Optional[CompressionQuality]):
imageCodec = codec.compressorFor(format)
with imageOps.switchRGBA_BGRA(self._image) as swapped:
compressed = imageCodec.compress(swapped, format, quality)
# image length only appears in compressed AEIs
if format.isCompressed:
fp.write(uint32(len(compressed), ENDIANNESS))
fp.write(compressed)
def _writeSymbols(self, fp: BinaryIO):
#TODO: Unimplemented
fp.write(uint16(0, ENDIANNESS)) # number of symbol groups
...
def _writeFooterMeta(self, fp: BinaryIO, quality: Optional[CompressionQuality]):
if quality is not None:
fp.write(uint8(quality, ENDIANNESS))
#endregion write-util
def close(self):
"""Close the underlying image.
"""
self._image.close()
def __enter__(self):
"""This method is called when entering a `with` statement.
"""
return self
def __exit__(self, exceptionType: Type[TException], exception: TException, trace: TracebackType):
"""This method is called when exiting a `with` statement.
"""
try:
self.close()
except:
pass