ConvertImage.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. #!/usr/bin/python3
  2. # Copyright (C) 2009-2021, Panagiotis Christopoulos Charitos and contributors.
  3. # All rights reserved.
  4. # Code licensed under the BSD License.
  5. # http://www.anki3d.org/LICENSE
  6. import argparse
  7. import subprocess
  8. import re
  9. import os
  10. import struct
  11. import copy
  12. import tempfile
  13. import shutil
  14. from PIL import Image
  15. from ctypes import Structure, sizeof, c_int, c_char, c_byte, c_uint
  16. from io import BytesIO
  17. import sys
  18. class CStruct(Structure):
  19. """ C structure """
  20. def to_bytearray(self):
  21. s = BytesIO()
  22. s.write(self)
  23. s.seek(0)
  24. return s.read()
  25. def from_bytearray(self, bin):
  26. s = BytesIO(bin)
  27. s.seek(0)
  28. s.readinto(self)
  29. class Config:
  30. in_files = []
  31. out_file = ""
  32. fast = False
  33. type = 0
  34. normal = False
  35. convert_path = ""
  36. no_alpha = False
  37. compressed_formats = 0
  38. store_uncompressed = True
  39. to_linear_rgb = False
  40. tmp_dir = ""
  41. #
  42. # AnKi texture
  43. #
  44. # Texture type
  45. TT_NONE = 0
  46. TT_2D = 1
  47. TT_CUBE = 2
  48. TT_3D = 3
  49. TT_2D_ARRAY = 4
  50. # Color format
  51. CF_NONE = 0
  52. CF_RGB8 = 1
  53. CF_RGBA8 = 2
  54. # Data compression
  55. DC_NONE = 0
  56. DC_RAW = 1 << 0
  57. DC_S3TC = 1 << 1
  58. DC_ETC2 = 1 << 2
  59. # Texture filtering
  60. TF_DEFAULT = 0
  61. TF_LINEAR = 1
  62. TF_NEAREST = 2
  63. #
  64. # DDS
  65. #
  66. # dwFlags of DDSURFACEDESC2
  67. DDSD_CAPS = 0x00000001
  68. DDSD_HEIGHT = 0x00000002
  69. DDSD_WIDTH = 0x00000004
  70. DDSD_PITCH = 0x00000008
  71. DDSD_PIXELFORMAT = 0x00001000
  72. DDSD_MIPMAPCOUNT = 0x00020000
  73. DDSD_LINEARSIZE = 0x00080000
  74. DDSD_DEPTH = 0x00800000
  75. # ddpfPixelFormat of DDSURFACEDESC2
  76. DDPF_ALPHAPIXELS = 0x00000001
  77. DDPF_FOURCC = 0x00000004
  78. DDPF_RGB = 0x00000040
  79. # dwCaps1 of DDSCAPS2
  80. DDSCAPS_COMPLEX = 0x00000008
  81. DDSCAPS_TEXTURE = 0x00001000
  82. DDSCAPS_MIPMAP = 0x00400000
  83. # dwCaps2 of DDSCAPS2
  84. DDSCAPS2_CUBEMAP = 0x00000200
  85. DDSCAPS2_CUBEMAP_POSITIVEX = 0x00000400
  86. DDSCAPS2_CUBEMAP_NEGATIVEX = 0x00000800
  87. DDSCAPS2_CUBEMAP_POSITIVEY = 0x00001000
  88. DDSCAPS2_CUBEMAP_NEGATIVEY = 0x00002000
  89. DDSCAPS2_CUBEMAP_POSITIVEZ = 0x00004000
  90. DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x00008000
  91. DDSCAPS2_VOLUME = 0x00200000
  92. class DdsHeader(CStruct):
  93. """ The header of a dds file """
  94. _fields_ = (
  95. ('dwMagic', c_char * 4),
  96. ('dwSize', c_int),
  97. ('dwFlags', c_int),
  98. ('dwHeight', c_int),
  99. ('dwWidth', c_int),
  100. ('dwPitchOrLinearSize', c_int),
  101. ('dwDepth', c_int),
  102. ('dwMipMapCount', c_int),
  103. ('dwReserved1', c_char * 44),
  104. # Pixel format
  105. ('dwSize', c_int),
  106. ('dwFlags', c_int),
  107. ('dwFourCC', c_char * 4),
  108. ('dwRGBBitCount', c_int),
  109. ('dwRBitMask', c_int),
  110. ('dwGBitMask', c_int),
  111. ('dwBBitMask', c_int),
  112. ('dwRGBAlphaBitMask', c_int),
  113. ('dwCaps1', c_int),
  114. ('dwCaps2', c_int),
  115. ('dwCapsReserved', c_char * 8),
  116. ('dwReserved2', c_int))
  117. #
  118. # ETC2
  119. #
  120. class PkmHeader:
  121. """ The header of a pkm file """
  122. _fields = [("magic", "6s"), ("type", "H"), ("width", "H"), ("height", "H"), ("origWidth", "H"), ("origHeight", "H")]
  123. def __init__(self, buff):
  124. buff_format = self.get_format()
  125. items = struct.unpack(buff_format, buff)
  126. for field, value in map(None, self._fields, items):
  127. setattr(self, field[0], value)
  128. @classmethod
  129. def get_format(cls):
  130. return ">" + "".join([f[1] for f in cls._fields])
  131. @classmethod
  132. def get_size(cls):
  133. return struct.calcsize(cls.get_format())
  134. #
  135. # Functions
  136. #
  137. def printi(s):
  138. print("[I] %s" % s)
  139. def printw(s):
  140. print("[W] %s" % s)
  141. def is_power2(num):
  142. """ Returns true if a number is a power of two """
  143. return num != 0 and ((num & (num - 1)) == 0)
  144. def get_base_fname(path):
  145. """ From path/to/a/file.ext return the "file" """
  146. return os.path.splitext(os.path.basename(path))[0]
  147. def parse_commandline():
  148. """ Parse the command line arguments """
  149. parser = argparse.ArgumentParser(description = "This program converts a single image or a number " \
  150. "of images (for 3D and 2DArray textures) to AnKi texture format. " \
  151. "It requires 4 different applications/executables to " \
  152. "operate: convert, identify, nvcompress and etcpack. These " \
  153. "applications should be in PATH except the convert where you " \
  154. "need to define the executable explicitly",
  155. formatter_class = argparse.ArgumentDefaultsHelpFormatter)
  156. parser.add_argument("-i",
  157. "--input",
  158. nargs="+",
  159. required=True,
  160. help="specify the image(s) to convert. Seperate with space")
  161. parser.add_argument("-o", "--output", required=True, help="specify output AnKi image.")
  162. parser.add_argument("-t",
  163. "--type",
  164. default="2D",
  165. choices=["2D", "3D", "2DArray"],
  166. help="type of the image (2D or cube or 3D or 2DArray)")
  167. parser.add_argument("-f", "--fast", type=int, default=0, help="run the fast version of the converters")
  168. parser.add_argument("-n", "--normal", type=int, default=0, help="assume the texture is normal")
  169. parser.add_argument("-c",
  170. "--convert-path",
  171. default="/usr/bin/convert",
  172. help="the executable where convert tool is located. Stupid etcpack cannot get it from PATH")
  173. parser.add_argument("--no-alpha", type=int, default=0, help="remove alpha channel")
  174. parser.add_argument("--store-uncompressed", type=int, default=0, help="store or not uncompressed data")
  175. parser.add_argument("--store-etc", type=int, default=0, help="store or not etc compressed data")
  176. parser.add_argument("--store-s3tc", type=int, default=1, help="store or not S3TC compressed data")
  177. parser.add_argument(
  178. "--to-linear-rgb",
  179. type=int,
  180. default=0,
  181. help="assume the input textures are sRGB. If this option is true then convert them to linear RGB")
  182. parser.add_argument("--filter",
  183. default="default",
  184. choices=["default", "linear", "nearest"],
  185. help="texture filtering. Can be: default, linear, nearest")
  186. parser.add_argument("--mip-count", type=int, default=0xFFFF, help="Max number of mipmaps")
  187. args = parser.parse_args()
  188. if args.type == "2D":
  189. typ = TT_2D
  190. elif args.type == "cube":
  191. typ = TT_CUBE
  192. elif args.type == "3D":
  193. typ = TT_3D
  194. elif args.type == "2DArray":
  195. typ = TT_2D_ARRAY
  196. else:
  197. assert 0, "See file"
  198. if args.filter == "default":
  199. filter = TF_DEFAULT
  200. elif args.filter == "linear":
  201. filter = TF_LINEAR
  202. elif args.filter == "nearest":
  203. filter = TF_NEAREST
  204. else:
  205. assert 0, "See file"
  206. if args.mip_count <= 0:
  207. parser.error("Wrong number of mipmaps")
  208. config = Config()
  209. config.in_files = args.input
  210. config.out_file = args.output
  211. config.fast = args.fast
  212. config.type = typ
  213. config.normal = args.normal
  214. config.convert_path = args.convert_path
  215. config.no_alpha = args.no_alpha
  216. config.store_uncompressed = args.store_uncompressed
  217. config.to_linear_rgb = args.to_linear_rgb
  218. config.filter = filter
  219. config.mip_count = args.mip_count
  220. if args.store_etc:
  221. config.compressed_formats = config.compressed_formats | DC_ETC2
  222. if args.store_s3tc:
  223. config.compressed_formats = config.compressed_formats | DC_S3TC
  224. return config
  225. def identify_image(in_file):
  226. """ Return the size of the input image and the internal format """
  227. img = Image.open(in_file)
  228. if img.mode == "RGB":
  229. color_format = CF_RGB8
  230. elif img.mode == "RGBA":
  231. color_format = CF_RGBA8
  232. else:
  233. raise Exception("Wrong format %s" % img.mode)
  234. # print some stuff and return
  235. printi("width: %s, height: %s color format: %s" % (img.width, img.height, img.mode))
  236. return (color_format, img.width, img.height)
  237. def create_mipmaps(in_file, tmp_dir, width_, height_, color_format, to_linear_rgb, max_mip_count):
  238. """ Create a number of images for all mipmaps """
  239. printi("Generate mipmaps")
  240. width = width_
  241. height = height_
  242. mips_fnames = []
  243. while width >= 4 and height >= 4:
  244. size_str = "%dx%d" % (width, height)
  245. out_file_str = os.path.join(tmp_dir, get_base_fname(in_file)) + "." + size_str
  246. mips_fnames.append(out_file_str)
  247. args = ["convert", in_file]
  248. # to linear
  249. if to_linear_rgb:
  250. if color_format != CF_RGB8:
  251. raise Exception("to linear RGB only supported for RGB textures")
  252. args.append("-set")
  253. args.append("colorspace")
  254. args.append("sRGB")
  255. args.append("-colorspace")
  256. args.append("RGB")
  257. # Add this because it will automatically convert gray-like images to grayscale TGAs
  258. #args.append("-type")
  259. #args.append("TrueColor")
  260. # resize
  261. args.append("-resize")
  262. args.append(size_str)
  263. # alpha
  264. args.append("-alpha")
  265. if color_format == CF_RGB8:
  266. args.append("deactivate")
  267. else:
  268. args.append("activate")
  269. args.append(out_file_str + ".png")
  270. printi(" " + " ".join(args))
  271. subprocess.check_call(args)
  272. if (len(mips_fnames) == max_mip_count):
  273. break
  274. width = width / 2
  275. height = height / 2
  276. return mips_fnames
  277. def create_etc_images(mips_fnames, tmp_dir, fast, color_format, convert_path):
  278. """ Create the etc files """
  279. printi("Creating ETC images")
  280. # Copy the convert tool to the working dir so that etcpack will see it
  281. shutil.copy2(convert_path, \
  282. os.path.join(tmp_dir, os.path.basename(convert_path)))
  283. for fname in mips_fnames:
  284. # Unfortunately we need to flip the image. Use convert again
  285. in_fname = fname + ".tga"
  286. flipped_fname = fname + "_flip.tga"
  287. args = ["convert", in_fname, "-flip", flipped_fname]
  288. subprocess.check_call(args)
  289. in_fname = flipped_fname
  290. printi(" %s" % in_fname)
  291. args = ["etcpack", in_fname, tmp_dir, "-c", "etc2"]
  292. if fast:
  293. args.append("-s")
  294. args.append("fast")
  295. args.append("-f")
  296. if color_format == CF_RGB8:
  297. args.append("RGB")
  298. else:
  299. args.append("RGBA")
  300. # Call the executable AND change the working directory so that etcpack will find convert
  301. subprocess.check_call(args, stdout=subprocess.PIPE, cwd=tmp_dir)
  302. def create_dds_images(mips_fnames, tmp_dir, fast, color_format, normal):
  303. """ Create the dds files """
  304. printi("Creating DDS images")
  305. for fname in mips_fnames:
  306. # Unfortunately we need to flip the image. Use convert again
  307. in_fname = fname + ".png"
  308. """
  309. flipped_fname = fname + "_flip.tga"
  310. args = ["convert", in_fname, "-flip", flipped_fname]
  311. subprocess.check_call(args)
  312. in_fname = flipped_fname
  313. """
  314. # Continue
  315. out_fname = os.path.join(tmp_dir, os.path.basename(fname) + ".dds")
  316. args = ["CompressonatorCLI", "-nomipmap"]
  317. if color_format == CF_RGB8:
  318. args.append("-fd")
  319. args.append("BC1")
  320. elif color_format == CF_RGBA8:
  321. args.append("-fd")
  322. args.append("BC3")
  323. args.append(in_fname)
  324. args.append(out_fname)
  325. printi(" " + " ".join(args))
  326. my_env = os.environ.copy()
  327. my_env["PATH"] = sys.path[0] + "/../../ThirdParty/Bin/compressonator/:" + my_env["PATH"]
  328. subprocess.check_call(args, stdout=subprocess.PIPE, env=my_env)
  329. def write_raw(tex_file, fname, width, height, color_format):
  330. """ Append raw data to the AnKi texture file """
  331. printi(" Appending %s" % fname)
  332. img = Image.open(fname)
  333. if img.size[0] != width or img.size[1] != height:
  334. raise Exception("Expecting different image size")
  335. if (img.mode == "RGB" and color_format != CF_RGB8) or (img.mode == "RGBA" and color_format != CF_RGBA8):
  336. raise Exception("Expecting different image format")
  337. if color_format == CF_RGB8:
  338. img = img.convert("RGB")
  339. else:
  340. img = img.convert("RGBA")
  341. data = bytearray()
  342. for h in range(0, int(height)):
  343. for w in range(0, int(width)):
  344. if color_format == CF_RGBA8:
  345. r, g, b, a = img.getpixel((w, h))
  346. data.append(r)
  347. data.append(g)
  348. data.append(b)
  349. data.append(a)
  350. else:
  351. r, g, b = img.getpixel((w, h))
  352. data.append(r)
  353. data.append(g)
  354. data.append(b)
  355. tex_file.write(data)
  356. def write_s3tc(out_file, fname, width, height, color_format):
  357. """ Append s3tc data to the AnKi texture file """
  358. # Read header
  359. printi(" Appending %s" % fname)
  360. in_file = open(fname, "rb")
  361. header_bin = in_file.read(sizeof(DdsHeader()))
  362. if len(header_bin) != sizeof(DdsHeader()):
  363. raise Exception("Failed to read DDS header")
  364. dds_header = DdsHeader()
  365. dds_header.from_bytearray(header_bin)
  366. if dds_header.dwWidth != width or dds_header.dwHeight != height:
  367. raise Exception("Incorrect width")
  368. if color_format == CF_RGB8 and dds_header.dwFourCC != b"DXT1":
  369. raise Exception("Incorrect format. Expecting DXT1")
  370. if color_format == CF_RGBA8 and dds_header.dwFourCC != b"DXT5":
  371. raise Exception("Incorrect format. Expecting DXT5")
  372. # Read and write the data
  373. if color_format == CF_RGB8:
  374. block_size = 8
  375. else:
  376. block_size = 16
  377. data_size = (width / 4) * (height / 4) * block_size
  378. data = in_file.read(int(data_size))
  379. if len(data) != data_size:
  380. raise Exception("Failed to read DDS data")
  381. # Make sure that the file doesn't contain any more data
  382. tmp = in_file.read(1)
  383. if len(tmp) != 0:
  384. printw(" File shouldn't contain more data")
  385. out_file.write(data)
  386. def write_etc(out_file, fname, width, height, color_format):
  387. """ Append etc2 data to the AnKi texture file """
  388. printi(" Appending %s" % fname)
  389. # Read header
  390. in_file = open(fname, "rb")
  391. header = in_file.read(PkmHeader.get_size())
  392. if len(header) != PkmHeader.get_size():
  393. raise Exception("Failed to read PKM header")
  394. pkm_header = PkmHeader(header)
  395. if pkm_header.magic != "PKM 20":
  396. raise Exception("Incorrect PKM header")
  397. if width != pkm_header.width or height != pkm_header.height:
  398. raise Exception("Incorrect PKM width or height")
  399. # Read and write the data
  400. data_size = (pkm_header.width / 4) * (pkm_header.height / 4) * 8
  401. data = in_file.read(data_size)
  402. if len(data) != data_size:
  403. raise Exception("Failed to read PKM data")
  404. # Make sure that the file doesn't contain any more data
  405. tmp = in_file.read(1)
  406. if len(tmp) != 0:
  407. printw(" File shouldn't contain more data")
  408. out_file.write(data)
  409. def convert(config):
  410. """ This is the function that does all the work """
  411. # Invoke app named "identify" to get internal format and width and height
  412. (color_format, width, height) = identify_image(config.in_files[0])
  413. if not is_power2(width) or not is_power2(height):
  414. raise Exception("Image width and height should power of 2")
  415. if color_format == CF_RGBA8 and config.normal:
  416. raise Exception("RGBA image and normal does not make much sense")
  417. for i in range(1, len(config.in_files)):
  418. (color_format_2, width_2, height_2) = identify_image(config.in_files[i])
  419. if width != width_2 or height != height_2 \
  420. or color_format != color_format_2:
  421. raise Exception("Images are not same size and color space")
  422. if config.no_alpha:
  423. color_format = CF_RGB8
  424. # Create images
  425. for in_file in config.in_files:
  426. mips_fnames = create_mipmaps(in_file, config.tmp_dir, width, height, color_format, config.to_linear_rgb,
  427. config.mip_count)
  428. # Create etc images
  429. if config.compressed_formats & DC_ETC2:
  430. create_etc_images(mips_fnames, config.tmp_dir, config.fast, color_format, config.convert_path)
  431. # Create dds images
  432. if config.compressed_formats & DC_S3TC:
  433. create_dds_images(mips_fnames, config.tmp_dir, config.fast, color_format, config.normal)
  434. # Open file
  435. fname = config.out_file
  436. printi("Writing %s" % fname)
  437. tex_file = open(fname, "wb")
  438. # Write header
  439. ak_format = "8sIIIIIIII"
  440. data_compression = config.compressed_formats
  441. if config.store_uncompressed:
  442. data_compression = data_compression | DC_RAW
  443. buff = struct.pack(ak_format, b"ANKITEX1", width, height, len(config.in_files), config.type, color_format,
  444. data_compression, config.normal, len(mips_fnames))
  445. tex_file.write(buff)
  446. # Write header padding
  447. header_padding_size = 128 - struct.calcsize(ak_format)
  448. if header_padding_size != 88:
  449. raise Exception("Check the header")
  450. padding = bytearray()
  451. for i in range(0, header_padding_size):
  452. padding.append(0)
  453. tex_file.write(padding)
  454. # For each compression
  455. for compression in range(0, 3):
  456. tmp_width = width
  457. tmp_height = height
  458. # For each level
  459. for i in range(0, len(mips_fnames)):
  460. # For each image
  461. for in_file in config.in_files:
  462. size_str = "%dx%d" % (tmp_width, tmp_height)
  463. in_base_fname = os.path.join(config.tmp_dir, get_base_fname(in_file)) + "." + size_str
  464. # Write RAW
  465. if compression == 0 and config.store_uncompressed:
  466. write_raw(tex_file, in_base_fname + ".png", tmp_width, tmp_height, color_format)
  467. # Write S3TC
  468. elif compression == 1 and (config.compressed_formats & DC_S3TC):
  469. write_s3tc(tex_file, in_base_fname + ".dds", tmp_width, tmp_height, color_format)
  470. # Write ETC
  471. elif compression == 2 and (config.compressed_formats & DC_ETC2):
  472. write_etc(tex_file, in_base_fname + "_flip.pkm", tmp_width, tmp_height, color_format)
  473. tmp_width = tmp_width / 2
  474. tmp_height = tmp_height / 2
  475. def main():
  476. """ The main """
  477. # Parse cmd line args
  478. config = parse_commandline()
  479. if config.type == TT_CUBE and len(config.in_files) != 6:
  480. raise Exception("Not enough images for cube generation")
  481. if (config.type == TT_3D or config.type == TT_2D_ARRAY) and len(config.in_files) < 2:
  482. #raise Exception("Not enough images for 2DArray/3D texture")
  483. printw("Not enough images for 2DArray/3D texture")
  484. printi("Number of images %u" % len(config.in_files))
  485. if config.type == TT_2D and len(config.in_files) != 1:
  486. raise Exception("Only one image for 2D textures needed")
  487. if not os.path.isfile(config.convert_path):
  488. raise Exception("Tool convert not found: " + config.convert_path)
  489. # Setup the temp dir
  490. config.tmp_dir = tempfile.mkdtemp("_ankitex")
  491. # Do the work
  492. try:
  493. convert(config)
  494. finally:
  495. shutil.rmtree(config.tmp_dir)
  496. #i = 0
  497. # Done
  498. printi("Done!")
  499. if __name__ == "__main__":
  500. main()