CreateAtlas.py 8.8 KB

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