create_atlas.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. #!/usr/bin/python3
  2. # Copyright (C) 2009-2018, 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. from PIL import Image, ImageDraw
  8. from math import *
  9. import os
  10. class SubImage:
  11. image = None
  12. image_name = ""
  13. width = 0
  14. height = 0
  15. mwidth = 0
  16. mheight = 0
  17. atlas_x = 0xFFFFFFFF
  18. atlas_y = 0xFFFFFFFF
  19. class Frame:
  20. x = 0
  21. y = 0
  22. w = 0
  23. h = 0
  24. @classmethod
  25. def diagonal(self):
  26. return sqrt(self.w * self.w + self.h * self.h)
  27. @classmethod
  28. def area(self):
  29. return self.w * self.h
  30. class Context:
  31. in_files = []
  32. out_file = ""
  33. margin = 0
  34. bg_color = 0
  35. rpath = ""
  36. sub_images = []
  37. atlas_width = 0
  38. atlas_height = 0
  39. mode = None
  40. def next_power_of_two(x):
  41. return pow(2.0, ceil(log(x) / log(2)))
  42. def printi(msg):
  43. print("[I] %s" % msg)
  44. def parse_commandline():
  45. """ Parse the command line arguments """
  46. parser = argparse.ArgumentParser(description = "This program creates a texture atlas",
  47. formatter_class = argparse.ArgumentDefaultsHelpFormatter)
  48. parser.add_argument("-i", "--input", nargs = "+", required = True,
  49. help = "specify the image(s) to convert. Seperate with space")
  50. parser.add_argument("-o", "--output", default = "atlas.png", help = "specify output PNG image.")
  51. parser.add_argument("-m", "--margin", type = int, default = 0, help = "specify the margin.")
  52. parser.add_argument("-b", "--background-color", help = "specify background of empty areas", default = "ff00ff00")
  53. parser.add_argument("-r", "--rpath", help = "Path to append to the .ankiatex", default = "")
  54. args = parser.parse_args()
  55. ctx = Context()
  56. ctx.in_files = args.input
  57. ctx.out_file = args.output
  58. ctx.margin = args.margin
  59. ctx.bg_color = int(args.background_color, 16)
  60. ctx.rpath = args.rpath
  61. if len(ctx.in_files) < 2:
  62. parser.error("Not enough images")
  63. return ctx
  64. def load_images(ctx):
  65. """ Load the images """
  66. for i in ctx.in_files:
  67. img = SubImage()
  68. img.image = Image.open(i)
  69. img.image_name = i
  70. if ctx.mode == None:
  71. ctx.mode = img.image.mode
  72. else:
  73. if ctx.mode != img.image.mode:
  74. raise Exception("Image \"%s\" has a different mode: \"%s\"" % (i, img.image.mode))
  75. img.width = img.image.size[0]
  76. img.height = img.image.size[1]
  77. img.mwidth = img.width + ctx.margin
  78. img.mheight = img.height + ctx.margin
  79. printi("Image \"%s\" loaded. Mode \"%s\"" % (i, img.image.mode))
  80. for simage in ctx.sub_images:
  81. if os.path.basename(simage.image_name) == os.path.basename(i):
  82. raise Exception("Cannot have images with the same base %s" % i)
  83. ctx.sub_images.append(img)
  84. def compute_atlas_rough_size(ctx):
  85. for i in ctx.sub_images:
  86. ctx.atlas_width += i.mwidth
  87. ctx.atlas_height += i.mheight
  88. ctx.atlas_width = next_power_of_two(ctx.atlas_width)
  89. ctx.atlas_height = next_power_of_two(ctx.atlas_height)
  90. def sort_image_key_diagonal(img):
  91. return img.width * img.width + img.height * img.height
  92. def sort_image_key_biggest_side(img):
  93. return max(img.width, img.height)
  94. def best_fit(img, crnt_frame, frame):
  95. if img.mwidth > frame.w or img.mheight > frame.h:
  96. return False
  97. if frame.area() < crnt_frame.area():
  98. return True
  99. else:
  100. return False
  101. def worst_fit(img, crnt_frame, new_frame):
  102. if img.mwidth > new_frame.w or img.mheight > new_frame.h:
  103. return False
  104. if new_frame.area() > crnt_frame.area():
  105. return True
  106. else:
  107. return False
  108. def closer_to_00(img, crnt_frame, new_frame):
  109. if img.mwidth > new_frame.w or img.mheight > new_frame.h:
  110. return False
  111. new_dist = new_frame.x * new_frame.x + new_frame.y * new_frame.y
  112. crnt_dist = crnt_frame.x * crnt_frame.x + crnt_frame.y * crnt_frame.y
  113. if new_dist < crnt_dist:
  114. return True
  115. else:
  116. return False
  117. def place_sub_images(ctx):
  118. """ Place the sub images in the atlas """
  119. # Sort the images
  120. ctx.sub_images.sort(key = sort_image_key_diagonal, reverse = True)
  121. frame = Frame()
  122. frame.w = ctx.atlas_width
  123. frame.h = ctx.atlas_height
  124. frames = []
  125. frames.append(frame)
  126. unplaced_imgs = []
  127. for i in range(0, len(ctx.sub_images)):
  128. unplaced_imgs.append(i)
  129. while len(unplaced_imgs) > 0:
  130. sub_image = ctx.sub_images[unplaced_imgs[0]]
  131. unplaced_imgs.pop(0)
  132. printi("Will try to place image \"%s\" of size %ux%d" %
  133. (sub_image.image_name, sub_image.width, sub_image.height))
  134. # Find best frame
  135. best_frame = None
  136. best_frame_idx = 0
  137. idx = 0
  138. for frame in frames:
  139. if not best_frame or closer_to_00(sub_image, best_frame, frame):
  140. best_frame = frame
  141. best_frame_idx = idx
  142. idx += 1
  143. assert best_frame != None, "See file"
  144. # Update the sub_image
  145. sub_image.atlas_x = best_frame.x + ctx.margin
  146. sub_image.atlas_y = best_frame.y + ctx.margin
  147. printi("Image placed in %dx%d" % (sub_image.atlas_x, sub_image.atlas_y))
  148. # Split frame
  149. frame_top = Frame()
  150. frame_top.x = best_frame.x + sub_image.mwidth
  151. frame_top.y = best_frame.y
  152. frame_top.w = best_frame.w - sub_image.mwidth
  153. frame_top.h = sub_image.mheight
  154. frame_bottom = Frame()
  155. frame_bottom.x = best_frame.x
  156. frame_bottom.y = best_frame.y + sub_image.mheight
  157. frame_bottom.w = best_frame.w
  158. frame_bottom.h = best_frame.h - sub_image.mheight
  159. frames.pop(best_frame_idx)
  160. frames.append(frame_top)
  161. frames.append(frame_bottom)
  162. def shrink_atlas(ctx):
  163. """ Compute the new atlas size """
  164. width = 0
  165. height = 0
  166. for sub_image in ctx.sub_images:
  167. width = max(width, sub_image.atlas_x + sub_image.width + ctx.margin)
  168. height = max(height, sub_image.atlas_y + sub_image.height + ctx.margin)
  169. ctx.atlas_width = next_power_of_two(width)
  170. ctx.atlas_height = next_power_of_two(height)
  171. def create_atlas(ctx):
  172. """ Create and populate the atlas """
  173. # Change the color to something PIL can understand
  174. bg_color = (ctx.bg_color >> 24)
  175. bg_color |= (ctx.bg_color >> 8) & 0xFF00
  176. bg_color |= (ctx.bg_color << 8) & 0xFF0000
  177. bg_color |= (ctx.bg_color << 24) & 0xFF000000
  178. mode = "RGB"
  179. if ctx.mode == "RGB":
  180. color_space = (255, 255, 255)
  181. else:
  182. mode = "RGBA"
  183. color_space = (255, 255, 255, 255)
  184. atlas_img = Image.new(mode, \
  185. (int(ctx.atlas_width), int(ctx.atlas_height)), color_space)
  186. draw = ImageDraw.Draw(atlas_img)
  187. draw.rectangle((0, 0, ctx.atlas_width, ctx.atlas_height), bg_color)
  188. for sub_image in ctx.sub_images:
  189. assert sub_image.atlas_x != 0xFFFFFFFF and sub_image.atlas_y != 0xFFFFFFFF, "See file"
  190. atlas_img.paste(sub_image.image, (int(sub_image.atlas_x), int(sub_image.atlas_y)))
  191. printi("Saving atlas \"%s\"" % ctx.out_file)
  192. atlas_img.save(ctx.out_file)
  193. def write_xml(ctx):
  194. """ Write the schema """
  195. fname = os.path.splitext(ctx.out_file)[0] + ".ankiatex"
  196. printi("Writing XML \"%s\"" % fname)
  197. f = open(fname, "w")
  198. f.write("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n")
  199. f.write("<textureAtlas>\n")
  200. out_filename = ctx.rpath + "/" + os.path.splitext(os.path.basename(ctx.out_file))[0] + ".ankitex"
  201. f.write("\t<texture>%s</texture>\n" % out_filename)
  202. f.write("\t<subTextureMargin>%u</subTextureMargin>\n" % ctx.margin)
  203. f.write("\t<subTextures>\n")
  204. for sub_image in ctx.sub_images:
  205. f.write("\t\t<subTexture>\n")
  206. f.write("\t\t\t<name>%s</name>\n" % os.path.basename(sub_image.image_name))
  207. # Now change coordinate system
  208. left = sub_image.atlas_x / ctx.atlas_width
  209. right = left + (sub_image.width / ctx.atlas_width)
  210. top = (ctx.atlas_height - sub_image.atlas_y) / ctx.atlas_height
  211. bottom = top - (sub_image.height / ctx.atlas_height)
  212. f.write("\t\t\t<uv>%f %f %f %f</uv>\n" % (left, bottom, right, top))
  213. f.write("\t\t</subTexture>\n")
  214. f.write("\t</subTextures>\n")
  215. f.write("</textureAtlas>\n")
  216. def main():
  217. """ The main """
  218. ctx = parse_commandline();
  219. load_images(ctx)
  220. compute_atlas_rough_size(ctx)
  221. place_sub_images(ctx)
  222. shrink_atlas(ctx)
  223. create_atlas(ctx)
  224. write_xml(ctx)
  225. if __name__ == "__main__":
  226. main()