| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679 |
- #!/usr/bin/python3
- # Copyright (C) 2009-2021, Panagiotis Christopoulos Charitos and contributors.
- # All rights reserved.
- # Code licensed under the BSD License.
- # http://www.anki3d.org/LICENSE
- import argparse
- import subprocess
- import re
- import os
- import struct
- import copy
- import tempfile
- import shutil
- from PIL import Image
- from ctypes import Structure, sizeof, c_int, c_char, c_byte, c_uint
- from io import BytesIO
- import sys
- class CStruct(Structure):
- """ C structure """
- def to_bytearray(self):
- s = BytesIO()
- s.write(self)
- s.seek(0)
- return s.read()
- def from_bytearray(self, bin):
- s = BytesIO(bin)
- s.seek(0)
- s.readinto(self)
- class Config:
- in_files = []
- out_file = ""
- fast = False
- type = 0
- normal = False
- convert_path = ""
- no_alpha = False
- compressed_formats = 0
- store_uncompressed = True
- to_linear_rgb = False
- tmp_dir = ""
- #
- # AnKi texture
- #
- # Texture type
- TT_NONE = 0
- TT_2D = 1
- TT_CUBE = 2
- TT_3D = 3
- TT_2D_ARRAY = 4
- # Color format
- CF_NONE = 0
- CF_RGB8 = 1
- CF_RGBA8 = 2
- # Data compression
- DC_NONE = 0
- DC_RAW = 1 << 0
- DC_S3TC = 1 << 1
- DC_ETC2 = 1 << 2
- # Texture filtering
- TF_DEFAULT = 0
- TF_LINEAR = 1
- TF_NEAREST = 2
- #
- # DDS
- #
- # dwFlags of DDSURFACEDESC2
- DDSD_CAPS = 0x00000001
- DDSD_HEIGHT = 0x00000002
- DDSD_WIDTH = 0x00000004
- DDSD_PITCH = 0x00000008
- DDSD_PIXELFORMAT = 0x00001000
- DDSD_MIPMAPCOUNT = 0x00020000
- DDSD_LINEARSIZE = 0x00080000
- DDSD_DEPTH = 0x00800000
- # ddpfPixelFormat of DDSURFACEDESC2
- DDPF_ALPHAPIXELS = 0x00000001
- DDPF_FOURCC = 0x00000004
- DDPF_RGB = 0x00000040
- # dwCaps1 of DDSCAPS2
- DDSCAPS_COMPLEX = 0x00000008
- DDSCAPS_TEXTURE = 0x00001000
- DDSCAPS_MIPMAP = 0x00400000
- # dwCaps2 of DDSCAPS2
- DDSCAPS2_CUBEMAP = 0x00000200
- DDSCAPS2_CUBEMAP_POSITIVEX = 0x00000400
- DDSCAPS2_CUBEMAP_NEGATIVEX = 0x00000800
- DDSCAPS2_CUBEMAP_POSITIVEY = 0x00001000
- DDSCAPS2_CUBEMAP_NEGATIVEY = 0x00002000
- DDSCAPS2_CUBEMAP_POSITIVEZ = 0x00004000
- DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x00008000
- DDSCAPS2_VOLUME = 0x00200000
- class DdsHeader(CStruct):
- """ The header of a dds file """
- _fields_ = (
- ('dwMagic', c_char * 4),
- ('dwSize', c_int),
- ('dwFlags', c_int),
- ('dwHeight', c_int),
- ('dwWidth', c_int),
- ('dwPitchOrLinearSize', c_int),
- ('dwDepth', c_int),
- ('dwMipMapCount', c_int),
- ('dwReserved1', c_char * 44),
- # Pixel format
- ('dwSize', c_int),
- ('dwFlags', c_int),
- ('dwFourCC', c_char * 4),
- ('dwRGBBitCount', c_int),
- ('dwRBitMask', c_int),
- ('dwGBitMask', c_int),
- ('dwBBitMask', c_int),
- ('dwRGBAlphaBitMask', c_int),
- ('dwCaps1', c_int),
- ('dwCaps2', c_int),
- ('dwCapsReserved', c_char * 8),
- ('dwReserved2', c_int))
- #
- # ETC2
- #
- class PkmHeader:
- """ The header of a pkm file """
- _fields = [("magic", "6s"), ("type", "H"), ("width", "H"), ("height", "H"), ("origWidth", "H"), ("origHeight", "H")]
- def __init__(self, buff):
- buff_format = self.get_format()
- items = struct.unpack(buff_format, buff)
- for field, value in map(None, self._fields, items):
- setattr(self, field[0], value)
- @classmethod
- def get_format(cls):
- return ">" + "".join([f[1] for f in cls._fields])
- @classmethod
- def get_size(cls):
- return struct.calcsize(cls.get_format())
- #
- # Functions
- #
- def printi(s):
- print("[I] %s" % s)
- def printw(s):
- print("[W] %s" % s)
- def is_power2(num):
- """ Returns true if a number is a power of two """
- return num != 0 and ((num & (num - 1)) == 0)
- def get_base_fname(path):
- """ From path/to/a/file.ext return the "file" """
- return os.path.splitext(os.path.basename(path))[0]
- def parse_commandline():
- """ Parse the command line arguments """
- parser = argparse.ArgumentParser(description = "This program converts a single image or a number " \
- "of images (for 3D and 2DArray textures) to AnKi texture format. " \
- "It requires 4 different applications/executables to " \
- "operate: convert, identify, nvcompress and etcpack. These " \
- "applications should be in PATH except the convert where you " \
- "need to define the executable explicitly",
- formatter_class = argparse.ArgumentDefaultsHelpFormatter)
- parser.add_argument("-i",
- "--input",
- nargs="+",
- required=True,
- help="specify the image(s) to convert. Seperate with space")
- parser.add_argument("-o", "--output", required=True, help="specify output AnKi image.")
- parser.add_argument("-t",
- "--type",
- default="2D",
- choices=["2D", "3D", "2DArray"],
- help="type of the image (2D or cube or 3D or 2DArray)")
- parser.add_argument("-f", "--fast", type=int, default=0, help="run the fast version of the converters")
- parser.add_argument("-n", "--normal", type=int, default=0, help="assume the texture is normal")
- parser.add_argument("-c",
- "--convert-path",
- default="/usr/bin/convert",
- help="the executable where convert tool is located. Stupid etcpack cannot get it from PATH")
- parser.add_argument("--no-alpha", type=int, default=0, help="remove alpha channel")
- parser.add_argument("--store-uncompressed", type=int, default=0, help="store or not uncompressed data")
- parser.add_argument("--store-etc", type=int, default=0, help="store or not etc compressed data")
- parser.add_argument("--store-s3tc", type=int, default=1, help="store or not S3TC compressed data")
- parser.add_argument(
- "--to-linear-rgb",
- type=int,
- default=0,
- help="assume the input textures are sRGB. If this option is true then convert them to linear RGB")
- parser.add_argument("--filter",
- default="default",
- choices=["default", "linear", "nearest"],
- help="texture filtering. Can be: default, linear, nearest")
- parser.add_argument("--mip-count", type=int, default=0xFFFF, help="Max number of mipmaps")
- args = parser.parse_args()
- if args.type == "2D":
- typ = TT_2D
- elif args.type == "cube":
- typ = TT_CUBE
- elif args.type == "3D":
- typ = TT_3D
- elif args.type == "2DArray":
- typ = TT_2D_ARRAY
- else:
- assert 0, "See file"
- if args.filter == "default":
- filter = TF_DEFAULT
- elif args.filter == "linear":
- filter = TF_LINEAR
- elif args.filter == "nearest":
- filter = TF_NEAREST
- else:
- assert 0, "See file"
- if args.mip_count <= 0:
- parser.error("Wrong number of mipmaps")
- config = Config()
- config.in_files = args.input
- config.out_file = args.output
- config.fast = args.fast
- config.type = typ
- config.normal = args.normal
- config.convert_path = args.convert_path
- config.no_alpha = args.no_alpha
- config.store_uncompressed = args.store_uncompressed
- config.to_linear_rgb = args.to_linear_rgb
- config.filter = filter
- config.mip_count = args.mip_count
- if args.store_etc:
- config.compressed_formats = config.compressed_formats | DC_ETC2
- if args.store_s3tc:
- config.compressed_formats = config.compressed_formats | DC_S3TC
- return config
- def identify_image(in_file):
- """ Return the size of the input image and the internal format """
- img = Image.open(in_file)
- if img.mode == "RGB":
- color_format = CF_RGB8
- elif img.mode == "RGBA":
- color_format = CF_RGBA8
- else:
- raise Exception("Wrong format %s" % img.mode)
- # print some stuff and return
- printi("width: %s, height: %s color format: %s" % (img.width, img.height, img.mode))
- return (color_format, img.width, img.height)
- def create_mipmaps(in_file, tmp_dir, width_, height_, color_format, to_linear_rgb, max_mip_count):
- """ Create a number of images for all mipmaps """
- printi("Generate mipmaps")
- width = width_
- height = height_
- mips_fnames = []
- while width >= 4 and height >= 4:
- size_str = "%dx%d" % (width, height)
- out_file_str = os.path.join(tmp_dir, get_base_fname(in_file)) + "." + size_str
- mips_fnames.append(out_file_str)
- args = ["convert", in_file]
- # to linear
- if to_linear_rgb:
- if color_format != CF_RGB8:
- raise Exception("to linear RGB only supported for RGB textures")
- args.append("-set")
- args.append("colorspace")
- args.append("sRGB")
- args.append("-colorspace")
- args.append("RGB")
- # Add this because it will automatically convert gray-like images to grayscale TGAs
- #args.append("-type")
- #args.append("TrueColor")
- # resize
- args.append("-resize")
- args.append(size_str)
- # alpha
- args.append("-alpha")
- if color_format == CF_RGB8:
- args.append("deactivate")
- else:
- args.append("activate")
- args.append(out_file_str + ".png")
- printi(" " + " ".join(args))
- subprocess.check_call(args)
- if (len(mips_fnames) == max_mip_count):
- break
- width = width / 2
- height = height / 2
- return mips_fnames
- def create_etc_images(mips_fnames, tmp_dir, fast, color_format, convert_path):
- """ Create the etc files """
- printi("Creating ETC images")
- # Copy the convert tool to the working dir so that etcpack will see it
- shutil.copy2(convert_path, \
- os.path.join(tmp_dir, os.path.basename(convert_path)))
- for fname in mips_fnames:
- # Unfortunately we need to flip the image. Use convert again
- in_fname = fname + ".tga"
- flipped_fname = fname + "_flip.tga"
- args = ["convert", in_fname, "-flip", flipped_fname]
- subprocess.check_call(args)
- in_fname = flipped_fname
- printi(" %s" % in_fname)
- args = ["etcpack", in_fname, tmp_dir, "-c", "etc2"]
- if fast:
- args.append("-s")
- args.append("fast")
- args.append("-f")
- if color_format == CF_RGB8:
- args.append("RGB")
- else:
- args.append("RGBA")
- # Call the executable AND change the working directory so that etcpack will find convert
- subprocess.check_call(args, stdout=subprocess.PIPE, cwd=tmp_dir)
- def create_dds_images(mips_fnames, tmp_dir, fast, color_format, normal):
- """ Create the dds files """
- printi("Creating DDS images")
- for fname in mips_fnames:
- # Unfortunately we need to flip the image. Use convert again
- in_fname = fname + ".png"
- """
- flipped_fname = fname + "_flip.tga"
- args = ["convert", in_fname, "-flip", flipped_fname]
- subprocess.check_call(args)
- in_fname = flipped_fname
- """
- # Continue
- out_fname = os.path.join(tmp_dir, os.path.basename(fname) + ".dds")
- args = ["CompressonatorCLI", "-nomipmap"]
- if color_format == CF_RGB8:
- args.append("-fd")
- args.append("BC1")
- elif color_format == CF_RGBA8:
- args.append("-fd")
- args.append("BC3")
- args.append(in_fname)
- args.append(out_fname)
- printi(" " + " ".join(args))
- my_env = os.environ.copy()
- my_env["PATH"] = sys.path[0] + "/../../ThirdParty/Bin/compressonator/:" + my_env["PATH"]
- subprocess.check_call(args, stdout=subprocess.PIPE, env=my_env)
- def write_raw(tex_file, fname, width, height, color_format):
- """ Append raw data to the AnKi texture file """
- printi(" Appending %s" % fname)
- img = Image.open(fname)
- if img.size[0] != width or img.size[1] != height:
- raise Exception("Expecting different image size")
- if (img.mode == "RGB" and color_format != CF_RGB8) or (img.mode == "RGBA" and color_format != CF_RGBA8):
- raise Exception("Expecting different image format")
- if color_format == CF_RGB8:
- img = img.convert("RGB")
- else:
- img = img.convert("RGBA")
- data = bytearray()
- for h in range(0, int(height)):
- for w in range(0, int(width)):
- if color_format == CF_RGBA8:
- r, g, b, a = img.getpixel((w, h))
- data.append(r)
- data.append(g)
- data.append(b)
- data.append(a)
- else:
- r, g, b = img.getpixel((w, h))
- data.append(r)
- data.append(g)
- data.append(b)
- tex_file.write(data)
- def write_s3tc(out_file, fname, width, height, color_format):
- """ Append s3tc data to the AnKi texture file """
- # Read header
- printi(" Appending %s" % fname)
- in_file = open(fname, "rb")
- header_bin = in_file.read(sizeof(DdsHeader()))
- if len(header_bin) != sizeof(DdsHeader()):
- raise Exception("Failed to read DDS header")
- dds_header = DdsHeader()
- dds_header.from_bytearray(header_bin)
- if dds_header.dwWidth != width or dds_header.dwHeight != height:
- raise Exception("Incorrect width")
- if color_format == CF_RGB8 and dds_header.dwFourCC != b"DXT1":
- raise Exception("Incorrect format. Expecting DXT1")
- if color_format == CF_RGBA8 and dds_header.dwFourCC != b"DXT5":
- raise Exception("Incorrect format. Expecting DXT5")
- # Read and write the data
- if color_format == CF_RGB8:
- block_size = 8
- else:
- block_size = 16
- data_size = (width / 4) * (height / 4) * block_size
- data = in_file.read(int(data_size))
- if len(data) != data_size:
- raise Exception("Failed to read DDS data")
- # Make sure that the file doesn't contain any more data
- tmp = in_file.read(1)
- if len(tmp) != 0:
- printw(" File shouldn't contain more data")
- out_file.write(data)
- def write_etc(out_file, fname, width, height, color_format):
- """ Append etc2 data to the AnKi texture file """
- printi(" Appending %s" % fname)
- # Read header
- in_file = open(fname, "rb")
- header = in_file.read(PkmHeader.get_size())
- if len(header) != PkmHeader.get_size():
- raise Exception("Failed to read PKM header")
- pkm_header = PkmHeader(header)
- if pkm_header.magic != "PKM 20":
- raise Exception("Incorrect PKM header")
- if width != pkm_header.width or height != pkm_header.height:
- raise Exception("Incorrect PKM width or height")
- # Read and write the data
- data_size = (pkm_header.width / 4) * (pkm_header.height / 4) * 8
- data = in_file.read(data_size)
- if len(data) != data_size:
- raise Exception("Failed to read PKM data")
- # Make sure that the file doesn't contain any more data
- tmp = in_file.read(1)
- if len(tmp) != 0:
- printw(" File shouldn't contain more data")
- out_file.write(data)
- def convert(config):
- """ This is the function that does all the work """
- # Invoke app named "identify" to get internal format and width and height
- (color_format, width, height) = identify_image(config.in_files[0])
- if not is_power2(width) or not is_power2(height):
- raise Exception("Image width and height should power of 2")
- if color_format == CF_RGBA8 and config.normal:
- raise Exception("RGBA image and normal does not make much sense")
- for i in range(1, len(config.in_files)):
- (color_format_2, width_2, height_2) = identify_image(config.in_files[i])
- if width != width_2 or height != height_2 \
- or color_format != color_format_2:
- raise Exception("Images are not same size and color space")
- if config.no_alpha:
- color_format = CF_RGB8
- # Create images
- for in_file in config.in_files:
- mips_fnames = create_mipmaps(in_file, config.tmp_dir, width, height, color_format, config.to_linear_rgb,
- config.mip_count)
- # Create etc images
- if config.compressed_formats & DC_ETC2:
- create_etc_images(mips_fnames, config.tmp_dir, config.fast, color_format, config.convert_path)
- # Create dds images
- if config.compressed_formats & DC_S3TC:
- create_dds_images(mips_fnames, config.tmp_dir, config.fast, color_format, config.normal)
- # Open file
- fname = config.out_file
- printi("Writing %s" % fname)
- tex_file = open(fname, "wb")
- # Write header
- ak_format = "8sIIIIIIII"
- data_compression = config.compressed_formats
- if config.store_uncompressed:
- data_compression = data_compression | DC_RAW
- buff = struct.pack(ak_format, b"ANKITEX1", width, height, len(config.in_files), config.type, color_format,
- data_compression, config.normal, len(mips_fnames))
- tex_file.write(buff)
- # Write header padding
- header_padding_size = 128 - struct.calcsize(ak_format)
- if header_padding_size != 88:
- raise Exception("Check the header")
- padding = bytearray()
- for i in range(0, header_padding_size):
- padding.append(0)
- tex_file.write(padding)
- # For each compression
- for compression in range(0, 3):
- tmp_width = width
- tmp_height = height
- # For each level
- for i in range(0, len(mips_fnames)):
- # For each image
- for in_file in config.in_files:
- size_str = "%dx%d" % (tmp_width, tmp_height)
- in_base_fname = os.path.join(config.tmp_dir, get_base_fname(in_file)) + "." + size_str
- # Write RAW
- if compression == 0 and config.store_uncompressed:
- write_raw(tex_file, in_base_fname + ".png", tmp_width, tmp_height, color_format)
- # Write S3TC
- elif compression == 1 and (config.compressed_formats & DC_S3TC):
- write_s3tc(tex_file, in_base_fname + ".dds", tmp_width, tmp_height, color_format)
- # Write ETC
- elif compression == 2 and (config.compressed_formats & DC_ETC2):
- write_etc(tex_file, in_base_fname + "_flip.pkm", tmp_width, tmp_height, color_format)
- tmp_width = tmp_width / 2
- tmp_height = tmp_height / 2
- def main():
- """ The main """
- # Parse cmd line args
- config = parse_commandline()
- if config.type == TT_CUBE and len(config.in_files) != 6:
- raise Exception("Not enough images for cube generation")
- if (config.type == TT_3D or config.type == TT_2D_ARRAY) and len(config.in_files) < 2:
- #raise Exception("Not enough images for 2DArray/3D texture")
- printw("Not enough images for 2DArray/3D texture")
- printi("Number of images %u" % len(config.in_files))
- if config.type == TT_2D and len(config.in_files) != 1:
- raise Exception("Only one image for 2D textures needed")
- if not os.path.isfile(config.convert_path):
- raise Exception("Tool convert not found: " + config.convert_path)
- # Setup the temp dir
- config.tmp_dir = tempfile.mkdtemp("_ankitex")
- # Do the work
- try:
- convert(config)
- finally:
- shutil.rmtree(config.tmp_dir)
- #i = 0
- # Done
- printi("Done!")
- if __name__ == "__main__":
- main()
|