This repository was archived by the owner on Oct 20, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathugoImage.py
executable file
·374 lines (326 loc) · 15.4 KB
/
ugoImage.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
#!/usr/bin/python3
# =========================
# ugoImage.py version 1.0.5
# =========================
#
# Convert images to and from Flipnote Studio's proprietary image formats (NFTF, NPF and NBF)
# Originally written for Sudomemo (github.com/Sudomemo | www.sudomemo.net)
# Implementation by Jaames (github.com/jaames | rakujira.jp)
# Support for NTFT and NBF formats based on work by PBSDS (github.com/pbsds | pbsds.net)
#
# Usage:
# ======
#
# Convert an NTFT, NBF or NPF to a standard image format like PNG:
# Python3 ugoImage.py -i input_path image_width image_height -o output_path
#
# Convert a standard image format like PNG to NTFT, NBF, or NPF:
# Python3 ugoImage.py -i input_path -o output_path
#
# Issues:
# =======
#
# If you find any bugs in this script, please report them here:
# https://github.com/Sudomemo/sudomemo-utils/issues
#
# Format documentation can be found on the Flipnote-Collective wiki:
# - nbf: https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/.nbf-image-format
# - npf: https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/.npf-image-format
# - nntft: https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/.ntft-image-format
#
# Requirements:
# - Python 3
# Installation: https://www.python.org/downloads/
# - The Pillow Image Library (https://python-pillow.org/)
# Installation: http://pillow.readthedocs.io/en/3.0.x/installation.html
# - NumPy (http://www.numpy.org/)
# Installation: https://www.scipy.org/install.html
from PIL import Image, ImageOps
from io import BytesIO
import numpy as np
VERSION = "1.0.5"
# Round up a number to the nearest power of two
# Flipnote's image formats really like power of twos
def roundToPower(value):
if value not in [256, 128, 64, 32, 16, 8, 4, 2, 1]:
p = 1
while 1 << p < value:
p += 1
return 1 << p
else:
return value
# Unpack an abgr1555 color
# value = 16-bit uint [1 bit - alpha][5 bits - blue][5 bits - green][5 bits - red]
# useAlpha = use True to read the alpha bit, else False
# Returns a 32-bit uint [8 bits - red][8 bits - green][8 bits - blue][8 bits - alpha]
def unpackColor(value, useAlpha=True):
r = (value & 0x1f)
g = (value >> 5 & 0x1f)
b = (value >> 10 & 0x1f)
a = (value >> 15 & 0x1)
r = r << 3 | (r >> 2)
g = g << 3 | (g >> 2)
b = b << 3 | (b >> 2)
return ((r << 24) | (g << 16) | (b << 8) | (0x00 if useAlpha and a == 0 else 0xFF))
# Pack an abgr1555 color
# color = [r, g, b, a (optional)]
# useAlpha = use True to use the alpha value, else False
# Returns a 16-bit uint [1 bit - alpha][5 bits - blue][5 bits - green][5 bits - red]
def packColor(color, useAlpha=True):
r = color[0] * 0x1F // 0xFF
g = color[1] * 0x1F // 0xFF
b = color[2] * 0x1F // 0xFF
a = 0 if useAlpha == True and color[3] < 0x80 else 1
# Combine them together into one 16-bit integer
return ((a<<15) | (b<<10) | (g<<5) | (r))
# Convenience method to apply unpackColor over an array
unpackColors = np.vectorize(unpackColor, otypes=[">u4"])
# Convenience method to apply packColor over an array of colors
def packColors(colors, useAlpha=True):
return np.apply_along_axis(packColor, 1, colors, useAlpha=useAlpha).astype(np.uint16)
# ugoImage class
class ugoImage:
def __init__(self, imageBuffer=None, imageFormat=None, imageWidth=0, imageHeight=0):
if imageBuffer:
self.load(imageBuffer, imageWidth, imageHeight, imageFormat)
def load(self, imageBuffer, imageFormat=None, imageWidth=0, imageHeight=0):
# Some prefer uppercase extentions over lowercase... I don't :P
imageFormat = imageFormat.lower()
if not imageFormat or imageFormat not in ["npf", "nbf", "ntft"]:
# Copy the image to an internal buffer
buffer = BytesIO()
buffer.write(imageBuffer.read())
self.image = Image.open(buffer)
elif imageFormat == "npf":
self.image = self.parseNpf(imageBuffer, imageWidth, imageHeight)
elif imageFormat == "nbf":
self.image = self.parseNbf(imageBuffer, imageWidth, imageHeight)
elif imageFormat == "ntft":
self.image = self.parseNtft(imageBuffer, imageWidth, imageHeight)
def save(self, outputBuffer, imageFormat):
imageFormat = imageFormat.lower()
if not imageFormat or imageFormat not in ["npf", "nbf", "ntft"]:
self.image.save(outputBuffer, imageFormat)
elif imageFormat == "npf":
self.writeNpf(outputBuffer)
elif imageFormat == "nbf":
self.writeNbf(outputBuffer)
elif imageFormat == "ntft":
self.writeNtft(outputBuffer)
# Write an UGAR header to an outputBuffer
# https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/.nbf-image-format#header
def _writeUgarHeader(self, outputBuffer, *sectionLengths):
# Start the section table from the section lengths given
sectionTable = np.array(sectionLengths, dtype=np.uint32)
# Insert the section count to the start of the section table
sectionTable = np.insert(sectionTable, 0, len(sectionTable))
# Write to the buffer
outputBuffer.write(b"UGAR")
outputBuffer.write(sectionTable.tobytes())
# Read an UGAR header from buffer
# https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/.nbf-image-format#header
# Returns a np array of section lengths
def _readUgarHeader(self, buffer):
# Skip the magic
buffer.seek(4)
sectionCount = np.fromstring(buffer.read(4), dtype=np.uint32)[0]
sectionTable = np.fromstring(buffer.read(4 * sectionCount), dtype=np.uint32)
return sectionTable
# If the image width isn't a power-of-two, then add padding until it is
# imageData = 1-D array of pixels
# imageSize = (width, height)
# Returns 1-D array of pixels, with padding added in
def _padImageData(self, imageData, imageSize):
width, height = imageSize
clipWidth = roundToPower(width)
# We use the "edge" padding mode to repeat the edge of each side until the image is the correct size
# Hatena's encoder did this, and they sometimes made use of this effect in theme images
if clipWidth != width:
# Reshape the image data into a 2D array
imageData = np.reshape(imageData, (-1, width))
imageData = np.pad(imageData, ((0, 0), (0, clipWidth - width)), "edge")
# Flatten back to a 1d array
return imageData.flatten()
# Else just return the imageData
return imageData
# Clip an image out of a power-of-two width image
# imageData = 1-D array of pixels
# imageSize = (width, height)
# Returns 2-D array of pixels
def _clipImageData(self, imageData, imageSize):
width, height = imageSize
# Round the width up to the nearest power of two
clipWidth = roundToPower(width)
# Reshape the image array to make a 2D array with the "real" image width / height
imageData = np.reshape(imageData, (-1, clipWidth))
# Clip the "requested" image size out of the "real" image
return imageData[0:height, 0:width]
# Limit the colors of an image
# image = PIL Image object
# paletteSlots = the number of colors to use
# Returns PIL Image with palette mode
def _limitImageColors(self, image, paletteSlots=0):
# Convert the image to RGB, then posterize to clamp the color channels to 5 bit values
image = image.convert("RGB")
image = ImageOps.posterize(image, 5)
return image.convert("P", palette=Image.ADAPTIVE, colors=paletteSlots)
# Reads an npf image from buffer, and returns an array of RGBA pixels
def parseNpf(self, buffer, imageWidth, imageHeight):
# Read the header
sectionLengths = self._readUgarHeader(buffer)
# Read the palette data (section number 1)
paletteData = np.frombuffer(buffer.read(roundToPower(sectionLengths[0])), dtype=np.uint16)
# Read the image data (section number 2)
imageData = np.frombuffer(buffer.read(sectionLengths[1]), dtype=np.uint8)
# NPF image data uses 1 byte per 2 pixels, so we need to split that byte into two
imageData = np.stack((np.bitwise_and(imageData, 0x0f), np.bitwise_and(imageData >> 4, 0x0f)), axis=-1).flatten()
# Unpack palette colors
palette = unpackColors(paletteData, useAlpha=False)
# Convert each pixel from a palette index to full color
pixels = np.fromiter((palette[i] if i > 0 else 0 for i in imageData), dtype=">u4")
# Clip the image data and create a Pillow image from it
return Image.fromarray(self._clipImageData(pixels, (imageWidth, imageHeight)), mode="RGBA")
# Write the image as an npf to buffer
def writeNpf(self, outputBuffer):
alphamap = self.image.split()[-1]
# Convert the image to a paletted format with 15 slots
image = self._limitImageColors(self.image, paletteSlots=15)
# Get the image palette
palette = np.reshape(image.getpalette(), (-1, 3))[0:15]
paletteData = packColors(palette, useAlpha=False)
paletteData = np.insert(paletteData, 0, 0)
# Get the image data and pad it
imageData = np.array(image.getdata(), dtype=np.uint8)
imageData = self._padImageData(imageData, image.size)
alphamap = self._padImageData(alphamap, image.size)
# Reshape image data so each item = 2 pixels
imageData = np.reshape(imageData, (-1, 2))
alphamap = np.reshape(alphamap, (-1, 2))
# Combine those groups of two pixels together into a single byte
imageData = np.array([(pix[0]+1 if a[0] > 128 else 0) | ((pix[1]+1 if a[1] > 128 else 0) << 4) for a, pix in zip(alphamap, imageData)], dtype=np.uint8)
# Write to buffer
self._writeUgarHeader(outputBuffer, paletteData.nbytes, imageData.nbytes)
outputBuffer.write(paletteData.tobytes())
outputBuffer.write(imageData.tobytes())
# Reads an nbf image from buffer, and returns an array of RGBA pixels
def parseNbf(self, buffer, imageWidth, imageHeight):
# Read the header
sectionLengths = self._readUgarHeader(buffer)
# Read the palette data (section number 1)
paletteData = np.frombuffer(buffer.read(sectionLengths[0]), dtype=np.uint16)
# Read the image data (section number 2)
imageData = np.frombuffer(buffer.read(sectionLengths[1]), dtype=np.uint8)
# Convert the palette to rgb888
palette = unpackColors(paletteData, useAlpha=False)
# Convert each pixel from a palette index to full color
pixels = np.fromiter((palette[pixel] for pixel in imageData), dtype=">u4")
# Clip the image data and create a Pillow image from it
return Image.fromarray(self._clipImageData(pixels, (imageWidth, imageHeight)), mode="RGBA")
# Write the image as an nbf to buffer
def writeNbf(self, outputBuffer):
image = self._limitImageColors(self.image, paletteSlots=256)
# Get the image palette
palette = np.reshape(image.getpalette(), (-1, 3))
# Pack the palette colors
paletteData = packColors(palette, useAlpha=False)
# Get the image data and add padding
imageData = np.array(image.getdata(), dtype=np.uint8)
imageData = self._padImageData(imageData, image.size)
# Write to file
self._writeUgarHeader(outputBuffer, paletteData.nbytes, imageData.nbytes)
outputBuffer.write(paletteData.tobytes())
outputBuffer.write(imageData.tobytes())
# Reads an ntft image from buffer, and returns an array of RGBA pixels
def parseNtft(self, buffer, imageWidth, imageHeight):
# Read the image data to an array
imageData = np.fromfile(buffer, dtype=np.uint16)
# Convert the image data from rgba5551 to rgba8888
pixels = unpackColors(imageData, useAlpha=True)
# Clip the image data and create a Pillow image from it
return Image.fromarray(self._clipImageData(pixels, (imageWidth, imageHeight)), mode="RGBA")
# Write the image as an btft to buffer
def writeNtft(self, outputBuffer):
imageData = self.image.getdata()
# Convert the pixel data to rgb
imageData = packColors(imageData, useAlpha=True)
imageData = self._padImageData(imageData, self.image.size)
outputBuffer.write(imageData.tobytes())
if __name__ == "__main__":
import sys, os
def representsInt(s):
try:
int(s)
return True
except ValueError:
return False
args = sys.argv[1::]
argIndex = 0
image = ugoImage()
if "-v" in args:
print(VERSION)
sys.exit()
if "-h" in args:
print("\n".join([
"",
"=========================",
"ugoImage.py version " + str(VERSION),
"=========================",
"",
"Convert images to and from Flipnote Studio's proprietary image formats (NFTF, NPF and NBF)",
"Originally written for Sudomemo (github.com/Sudomemo | www.sudomemo.net)",
"Implementation by Jaames (github.com/jaames | rakujira.jp)",
"Support for NTFT and NBF formats based on work by PBSDS (github.com/pbsds | pbsds.net)",
"",
"Usage:",
"======",
"",
"Convert an NTFT, NBF or NPF to a standard image format like PNG:",
"Python3 ugoImage.py -i input_path image_width image_height -o output_path",
"",
"Convert a standard image format like PNG to NTFT, NBF, or NPF:",
"Python3 ugoImage.py -i input_path -o output_path",
"",
"Issues:",
"=======",
"",
"If you find any bugs in this script, please report them here:",
"https://github.com/Sudomemo/sudomemo-utils/issues",
""
]))
sys.exit()
if "-i" not in args:
print("No input specified")
sys.exit()
if "-o" not in args:
print("No output specified")
sys.exit()
while argIndex < len(args):
arg = args[argIndex]
# Input path
if arg == "-i":
path = args[argIndex + 1]
filename, extension = os.path.splitext(path)
extension = extension.split(".")[1]
if extension.lower() in ["nbf", "ntft", "npf"]:
if not representsInt(args[argIndex + 2]) or not representsInt(args[argIndex + 3]):
print("Error: width and height must be specified for " + filename + "." + extension)
sys.exit()
else:
width = int(args[argIndex + 2])
height = int(args[argIndex + 3])
with open(os.path.abspath(path), "rb") as infile:
image.load(infile, imageFormat=extension, imageWidth=width, imageHeight=height)
argIndex += 4
else:
with open(os.path.abspath(path), "rb") as infile:
image.load(infile, imageFormat=extension)
argIndex += 2
# Output path
elif arg == "-o":
path = args[argIndex + 1]
filename, extension = os.path.splitext(path)
extension = extension.split(".")[1]
with open(path, "wb") as outfile:
image.save(outfile, imageFormat=extension)
argIndex += 2
# James is really cool, btw