ankitexture.py 16 KB

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