fxconv: rewrite image converter, forcing alpha value

This commit is contained in:
Lephenixnoir 2022-05-14 12:38:52 +01:00
parent 58cb14157d
commit b29c494715
No known key found for this signature in database
GPG key ID: 1BBA026E13FC0495

View file

@ -90,40 +90,50 @@ FX_PROFILES = [
# fx-CG 50 profiles # fx-CG 50 profiles
class CgProfile: class CgProfile:
def __init__(self, id, name, alpha): def __init__(self, id, depth, names, alpha=None, palette=None):
""" # Numerical ID
Construct a CgProfile object.
* [id] is the profile ID in bopti
* [name] is the profile name as found in the specification key
* [alpha] is True if this profile supports alpha, False otherwise
"""
self.id = id self.id = id
self.name = name # Name for printing
self.supports_alpha = alpha self.names = names
# Bit depth
self.depth = depth
# Whether the profile has alpha
self.has_alpha = (alpha is not None)
# Alpha value (None has_alpha == False)
self.alpha = alpha
# Whether the profile is indexed
self.is_indexed = (palette is not None)
if palette is not None:
# Palette base value (skipping the alpha value)
self.palette_base = palette[0]
# Color count (indices are always 0..color_count-1, wraps around)
self.color_count = palette[1]
# Whether to trim the palette to a minimal length
self.trim_palette = palette[2]
@staticmethod @staticmethod
def find(name): def find(name):
"""Find a profile by name.""" """Find a profile by name."""
for profile in CG_PROFILES: for profile in CG_PROFILES:
if profile.name == name: if name in profile.names:
return profile return profile
return None return None
IMAGE_RGB16 = 0
IMAGE_P8 = 1
IMAGE_P4 = 2
CG_PROFILES = [ CG_PROFILES = [
# 16-bit RGB565 and RGB565 with alpha # 16-bit RGB565 and RGB565 with alpha
CgProfile(0x0, "rgb565", False), CgProfile(0x0, IMAGE_RGB16, ["rgb565", "r5g6b5"]),
CgProfile(0x1, "rgb565a", True), CgProfile(0x1, IMAGE_RGB16, ["rgb565a", "r5g6b5a"], alpha=0x0001),
# 8-bit palette for RGB565 and RGB565A # 8-bit palette for RGB565 and RGB565A
CgProfile(0x4, "p8_rgb565", False), CgProfile(0x4, IMAGE_P8, "p8_rgb565", palette=(0x80,256,True)),
CgProfile(0x5, "p8_rgb565a", True), CgProfile(0x5, IMAGE_P8, "p8_rgb565a", alpha=0x80, palette=(0x81,256,True)),
# 4-bit palette for RGB565 and RGB565A # 4-bit palette for RGB565 and RGB565A
CgProfile(0x6, "p4_rgb565", False), CgProfile(0x6, IMAGE_P4, "p4_rgb565", palette=(0,16,False)),
CgProfile(0x3, "p4_rgb565a", True), CgProfile(0x3, IMAGE_P4, "p4_rgb565a", alpha=0, palette=(1,16,False)),
# Original names for RGB565 and RGB565A
CgProfile(0x0, "r5g6b5", False),
CgProfile(0x1, "r5g6b5a", True),
] ]
# Libimg flags # Libimg flags
@ -511,7 +521,19 @@ def _image_project(img, f):
# Image conversion for fx-CG 50 # Image conversion for fx-CG 50
# #
def image_has_alpha(img):
# Convert the alpha channel to 1-bit; check if there are transparent pixels
try:
alpha_channel = img.getchannel("A").convert("1", dither=Image.NONE)
alpha_levels = { t[1]: t[0] for t in alpha_channel.getcolors() }
return 0 in alpha_levels
except ValueError:
return False
def convert_bopti_cg(input, params): def convert_bopti_cg(input, params):
return convert_image_cg(input, params)
def convert_image_cg(input, params):
if isinstance(input, Image.Image): if isinstance(input, Image.Image):
img = input.copy() img = input.copy()
else: else:
@ -523,68 +545,48 @@ def convert_bopti_cg(input, params):
area = Area(params.get("area", {}), img) area = Area(params.get("area", {}), img)
img = img.crop(area.tuple()) img = img.crop(area.tuple())
# If no profile is specified, fall back to rgb565 or rgb565a later on img = img.convert("RGBA")
name = params.get("profile", None)
if name is not None:
profile = CgProfile.find(name)
if name in ["r5g6b5", "r5g6b5a", "rgb565", "rgb565a", None]: #---
# Encode the image into the 16-bit format # Format selection
encoded, stride, alpha = r5g6b5(img) #---
if name is None: format_name = params.get("profile", "")
name = "rgb565" if alpha is None else "rgb565a" has_alpha = image_has_alpha(img)
profile = CgProfile.find(name)
color_count = -1 # If no format is specified, select rgb565 or rgb565a
palette = None if format_name == "":
format_name = "rgb565a" if has_alpha else "rgb565"
# Similarly, if "p8" or "p4" is specified, select the cheapest variation
elif format_name == "p8":
format_name = "p8_rgb565a" if has_alpha else "p8_rgb565"
elif format_name == "p4":
format_name = "p4_rgb565a" if has_alpha else "p4_rgb565"
elif name.startswith("p"): # Otherwise, just use the format as specified
force_alpha = name.endswith("_rgb565a") format = CgProfile.find(format_name)
if name.startswith("p8"): if format is None:
trim_palette = True raise FxconvError(f"unknown image format '{format_name}")
palette_base = 0x80
color_count = 256
elif name.startswith("p4"):
trim_palette = False
palette_base = 0x00
color_count = 16
else:
raise FxconvError(f"unknown palette format {profile}")
# Encode the image into 16-bit with a palette of 16 or 256 entries # Check that we have transparency support if we need it
encoded, stride, palette, alpha = r5g6b5(img, color_count=color_count, if has_alpha and not format.has_alpha:
trim_palette=trim_palette, palette_base=palette_base, raise FxconvError(f"'{input}' has transparency, which {format_name} "+
force_alpha=force_alpha) "doesn't support")
color_count = len(palette) // 2 #---
# Structure generation
#---
else: data, stride, palette, color_count = image_encode(img, format)
raise FxconvError(f"unknown color profile '{name}'")
# Resolve "p8" and "p4" to their optimal variation
if name == "p8":
name = "p8_rgb565" if alpha is None else "p8_rgb565a"
profile = CgProfile.find(name)
elif name == "p4":
name = "p4_rgb565" if alpha is None else "p4_rgb565a"
profile = CgProfile.find(name)
if alpha is not None and not profile.supports_alpha:
raise FxconvError(f"'{input}' has transparency; use rgb565a, " +
"p8_rgb565a or p4_rgb565a")
if len(encoded) % 4 != 0:
encoded += bytes(4 - (len(encoded) % 4))
o = ObjectData() o = ObjectData()
o += u8(profile.id) o += u8(format.id)
o += u8(3) # DATA_RO, PALETTE_RO o += u8(3) # DATA_RO, PALETTE_RO
o += u16(alpha if alpha is not None else 0xffff) o += u16(format.alpha if format.alpha is not None else 0xffff)
o += u16(img.width) o += u16(img.width)
o += u16(img.height) o += u16(img.height)
o += u32(stride) o += u32(stride)
o += ref(encoded, padding=4) o += ref(data, padding=4)
o += u32(color_count) o += u32(color_count)
if palette is None: if palette is None:
o += u32(0) o += u32(0)
@ -856,7 +858,8 @@ def convert_libimg_cg(input, params):
img = img.crop(area.tuple()) img = img.crop(area.tuple())
# Encode the image into 16-bit format and force the alpha to 0x0001 # Encode the image into 16-bit format and force the alpha to 0x0001
encoded, stride, alpha = r5g6b5(img, force_alpha=(0x0001,0x0000)) format = CgProfile.find("rgb565a")
encoded, stride, palette, color_count = image_encode(img, format)
o = ObjectData() o = ObjectData()
o += u16(img.width) + u16(img.height) o += u16(img.width) + u16(img.height)
@ -942,218 +945,141 @@ def quantize(img, dither=False):
return img return img
def r5g6b5(img, color_count=0, trim_palette=False, palette_base=0, def image_encode(img, format):
force_alpha=None):
""" """
Convert a PIL.Image.Image into an RGB565 byte stream. If there are Encodes a PIL.Image.Image into one of the fx-CG image formats. The color
transparent pixels, chooses a color to implement alpha and replaces them depth is either RGB16, P8 or P4, with various transparency settings and
with this color. palette encodings.
When color_count=0, converts the image to 16-bit; returns a bytearray of Returns 4 values:
pixel data, the byte stride, and the automatically-chosen alpha value (or * data: A bytearray containing the encoded image data
None). * stride: The byte stride of the data array
* palette: A bytearray containing the encoded palette (None if not indexed)
* If force_alpha is a pair (value, replacement), then the alpha is forced * color_count: Number of colors in the palette (-1 if not indexed)
to be the indicated value, and any natural occurrence of the value is
substituted with the replacement.
When color_count>0, if should be either 16 or 256. The image is encoded
with a palette of that size. Returns the converted image as a bytearray,
the byte stride, the palette as a bytearray, and the alpha value (None if
there were no transparent pixels).
* If trim_palette is set, the palette bytearray is trimmed so that only
used entries are set (this does not include alpha, which is at the end).
* If palette_base is provided, palette entries will be numbered starting
from that value, wrapping around modulo color_count.
* If force_alpha=True, an index will be reserved for alpha even if no pixel
is transparent in the source image.
""" """
def rgb24to16(rgb24):
r, g, b = rgb24
r = (r & 0xff) >> 3
g = (g & 0xff) >> 2
b = (b & 0xff) >> 3
return (r << 11) | (g << 5) | b
#--- #---
# Initial image transforms # Separate the alpha channel
# Separate the alpha channel and generate a first palette.
#--- #---
# Save the alpha channel and make it 1-bit. If there are transparent # Save the alpha channel and make it 1-bit. If there are transparent
# pixels, set has_alpha=True and record the alpha channel in alpha_pixels. # pixels, set has_alpha=True and record the alpha channel in alpha_pixels.
try: if format.has_alpha:
alpha_channel = img.getchannel("A").convert("1", dither=Image.NONE) alpha_channel = img.getchannel("A").convert("1", dither=Image.NONE)
alpha_levels = { t[1]: t[0] for t in alpha_channel.getcolors() } else:
has_alpha = 0 in alpha_levels alpha_channel = Image.new("1", img.size, 1)
replacement = None
if has_alpha: alpha_pixels = alpha_channel.load()
alpha_pixels = alpha_channel.load()
except ValueError:
has_alpha = False
# Convert the input image to RGB
img = img.convert("RGB") img = img.convert("RGB")
# Transparent pixels also have values on the RGB channels, so they use up a # Transparent pixels have random values on the RGB channels, causing them
# palette entry (in indexed mode) or possible alpha value (in 16-bit mode) # to use up palette entries during quantization. To avoid that, set their
# even though their color is unused. Replace them with a non-transparent # RGB data to a color used somewhere else in the image.
# color used elsewhere in the image to avoid that. pixels = img.load()
if has_alpha: bg_color = next((pixels[x,y]
nontransparent_pixels = { (x,y) for x in range(img.width) for y in range(img.height)
for x in range(img.width) for y in range(img.height) if alpha_pixels[x,y] > 0),
if alpha_pixels[x,y] > 0 } (0,0,0))
if nontransparent_pixels: for y in range(img.height):
x0, y0 = nontransparent_pixels.pop() for x in range(img.width):
pixels = img.load() if alpha_pixels[x,y] == 0:
pixels[x,y] = bg_color
for y in range(img.height): #---
for x in range(img.width): # Quantize to generate a palette
if alpha_pixels[x,y] == 0: #---
pixels[x,y] = pixels[x0,y0]
# In indexed mode, generate a specific palette if format.is_indexed:
if color_count: palette_max_size = format.color_count - int(format.has_alpha)
palette_max_size = color_count - int(has_alpha or force_alpha == True)
img = img.convert("P", img = img.convert("P",
dither=Image.NONE, dither=Image.NONE,
palette=Image.ADAPTIVE, palette=Image.ADAPTIVE,
colors=palette_max_size) colors=palette_max_size)
# Format for the first palette is a list of N triplets where N is the # The palette format is a list of N triplets where N includes both the
# number of used opaque colors; obviously N <= palette_max_size # opaque colors we just generated and an alpha color. Sometimes colors
# Note: sometimes colors are not numbered 0..N-1, so we take the max # after img.convert() are not numbered 0..N-1, so take the max.
# value rather than len(img.getcolors()); we don't try to remap indices
pixels = img.load() pixels = img.load()
N = 1 + max(pixels[x,y] N = 1 + max(pixels[x,y]
for y in range(img.height) for y in range(img.height) for x in range(img.width))
for x in range(img.width))
palette1 = img.getpalette()[:3*N] palette = img.getpalette()[:3*N]
palette1 = list(zip(palette1[::3], palette1[1::3], palette1[2::3])) palette = list(zip(palette[::3], palette[1::3], palette[2::3]))
# For formats with transparency, make the transparent color consistent
if format.has_alpha:
N += 1
palette = [(255, 0, 255)] + palette
# Also keep track of how to remap indices from the values generated by
# img.convert() into the palette, which is shifted by 1 due to alpha
# and also starts at format.palette_base. Note: format.palette_base
# already starts 1 value later for formats with alpha.
palette_map = [(format.palette_base + i) % format.color_count
for i in range(N)]
else: else:
pixels = img.load() px = img.load()
#--- #---
# Alpha encoding # Encode data into a bytearray
# Find a value to encode transparency and map it into palettes.
#--- #---
# RGB565A with fixed alpha value (fi. alpha=0x0001 in libimg) def rgb24to16(rgb):
if color_count == 0 and force_alpha is not None: r = (rgb[0] & 0xff) >> 3
alpha, replacement = force_alpha g = (rgb[1] & 0xff) >> 2
b = (rgb[2] & 0xff) >> 3
return (r << 11) | (g << 5) | b
# For palettes, anything works; use the next value if format.depth == IMAGE_RGB16:
elif color_count > 0 and (has_alpha or force_alpha == True): # Preserve alignment between rows by padding to 4 bytes
alpha = N
# Find an unused RGB565 value and keep the encoding to 16-bit
elif has_alpha:
colormap = { rgb24to16(pixels[x, y])
for x in range(img.width) for y in range(img.height)
if alpha_pixels[x, y] > 0 }
available = set(range(65536)) - colormap
if not available:
raise FxconvError("image uses all 65536 colors and alpha")
alpha = available.pop()
# No transparency in the image
else:
alpha = None
# Function to encode with alpha support in RGB565
def alpha_encoding(color, a):
if a > 0: # pixel is not transparent
return color if color != alpha else replacement
else:
return alpha
# In palette formats, rearrange the palette to account for modulo numbering
# and trim the palette if needed
if color_count > 0:
total = len(palette1) + int(has_alpha or force_alpha == True)
# The palette_map list associates to indices into palette1, the pixel
# value in the data array (which starts at palette_base)
palette_map = [(palette_base + i) % color_count for i in range(total)]
#---
# Image encoding
# Create byte arrays with pixel data and palette data
#---
stride = 0
if not color_count:
# In RGB16, preserve alignment between rows
stride = (img.width + 1) // 2 * 4 stride = (img.width + 1) // 2 * 4
size = stride * img.height * 2 size = stride * img.height * 2
elif color_count == 256: elif format.depth == IMAGE_P8:
# No constraint in P8
size = img.width * img.height size = img.width * img.height
stride = img.width stride = img.width
elif color_count == 16: elif format.depth == IMAGE_P4:
# In P4, pad whole bytes # Pad whole bytes
stride = (img.width + 1) // 2 stride = (img.width + 1) // 2
size = stride * img.height size = stride * img.height
# Result of encoding # Encode the pixel data
encoded = bytearray(size) data = bytearray(size)
# Offset into the array
offset = 0
for y in range(img.height): for y in range(img.height):
for x in range(img.width): for x in range(img.width):
a = alpha_pixels[x, y] if has_alpha else 255 a = alpha_pixels[x,y]
if not color_count: if format.depth == IMAGE_RGB16:
c = alpha_encoding(rgb24to16(pixels[x, y]), a) # If c lands on the alpha value, flip its lowest bit
c = rgb24to16(pixels[x, y])
c = format.alpha if (a == 0) else c ^ (c == format.alpha)
offset = (stride * y) + x * 2 offset = (stride * y) + x * 2
encoded[offset:offset+2] = u16(c) data[offset:offset+2] = u16(c)
elif color_count == 16: elif format.depth == IMAGE_P8:
c = palette_map[pixels[x, y] if a > 0 else alpha] c = palette_map[pixels[x, y]] if a > 0 else format.alpha
offset = (stride * y) + x
data[offset] = c
# Select either the 4 MSb or 4 LSb of the current byte elif format.depth == IMAGE_P4:
c = palette_map[pixels[x, y]] if a > 0 else format.alpha
offset = (stride * y) + (x // 2)
if x % 2 == 0: if x % 2 == 0:
encoded[offset] |= (c << 4) data[offset] |= (c << 4)
else: else:
encoded[offset] |= c data[offset] |= c
offset += (x % 2 == 1) or (x == img.width - 1) # Encode the palette
elif color_count == 256: if format.is_indexed:
c = palette_map[pixels[x, y] if a > 0 else alpha] N = N if format.trim_palette else format.color_count
encoded[offset] = c encoded_palette = bytearray(2 * N)
offset += 1 for i, rgb24 in enumerate(palette):
encoded_palette[2*i:2*i+2] = u16(rgb24to16(rgb24))
# Encode the palette as R5G6B5 return data, stride, encoded_palette, N
if color_count > 0:
if trim_palette:
encoded_palette = bytearray(2 * len(palette_map))
else:
encoded_palette = bytearray(2 * color_count)
for i in range(len(palette1)):
encoded_palette[2*i:2*i+2] = u16(rgb24to16(palette1[i]))
#---
# Outro
#---
if color_count > 0:
if alpha is not None:
alpha = palette_map[alpha]
return encoded, stride, encoded_palette, alpha
else: else:
return encoded, stride, alpha return data, stride, None, -1
def convert(input, params, target, output=None, model=None, custom=None): def convert(input, params, target, output=None, model=None, custom=None):
""" """