make_thumbnails.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. #!/usr/bin/env python3
  2. """Build materials/<encoded>.png thumbnails: curated Wikimedia Commons photos where a
  3. freely licensed photo actually depicts the material, generated swatches otherwise."""
  4. import io, os, re, sys, time, math, random, json
  5. import requests
  6. from PIL import Image, ImageDraw, ImageFilter
  7. ROOT = "/home/zoadian/projects/visicut_profiles"
  8. SIZE = 160
  9. API = "https://commons.wikimedia.org/w/api.php"
  10. UA = {"User-Agent": "visicut-settings-thumbnailer/1.0 (https://wiki.fablab-rothenburg.de/; claudeai@zoadian.de)"}
  11. ALLOWED = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-")
  12. def tp(n): return "".join(c if c in ALLOWED else "_%d_" % ord(c) for c in n)
  13. # material -> (Commons file title, optional crop hint "cx,cy" in 0..1 of the source)
  14. PHOTO = {
  15. "Sperrholz Birke": "Birch plywood.jpg",
  16. "Sperrholz Pappel": "Poplar plywood.png",
  17. "Sperrholz Buche": "Buche gedämpft.JPG",
  18. "Sperrholz Gabun": "Okumé.JPG",
  19. "Sperrholz Linde": "Plywood texture.JPG",
  20. "Acorn Sperrholz": "Wood 044 plywood.jpg",
  21. "Kiefer Massivholz": "4 conifer wood samples.jpg",
  22. "MDF": "MDF Sample.jpg",
  23. "Hartfaserplatte": "Isorel, masonite, hardboard..jpg",
  24. "Wellpappe": "Corrugated Cardboard.JPG",
  25. "Finnpappe": "CardboardSet001 16K-PNG Color.png",
  26. "Kraftplex": "Kraft tileable 1024x1024.png",
  27. "Dickes Bastelpapier": "Construction Paper.JPG",
  28. "Moosgummi": "EVA sheet.png",
  29. "PP-Folie": "9 x Plakene Oasis Dina4 - size A4 - 0.5 mm - A.jpg",
  30. "Leder (chromfrei)": "Last Drip Designs Keycase.jpg",
  31. }
  32. # generated swatches: (base colour, texture style, extra)
  33. SWATCH = {
  34. "Acrylglas (PMMA)": ("#cfe3ef", "gloss"),
  35. "Glas (nur Gravieren)":("#d8e6e4", "glass"),
  36. "PET-Folie": ("#dfe7ea", "film"),
  37. "Silikon Gummimatte": ("#b9bdc0", "matte"),
  38. "Stempelgummi": ("#8a5a3a", "matte"),
  39. "Tesa 6930 Laserfolie":("#e8e6df", "film"),
  40. "Eloxiertes Blech": ("#b6bcc2", "brushed"),
  41. "Judopokal-Platte": ("#d8ae3c", "brushed"),
  42. "Papier": ("#f5f3ee", "fibre"),
  43. "Karton 160g": ("#ece2cd", "fibre"),
  44. "Karton 200g": ("#e4d8bf", "fibre"),
  45. "Karton 300g": ("#dccbab", "fibre"),
  46. "Siebdruckkarton": ("#cbb894", "fibre"),
  47. "Kromapappe": ("#8e8d88", "fibre"),
  48. "Baumwollstoff": ("#e6e0d4", "weave"),
  49. "SnapPap": ("#b58a5e", "crumple"),
  50. "Depron Schaum (EPP)": ("#eceff1", "foam"),
  51. "Airplak Kartonschaum":("#efe9dc", "foam"),
  52. "Sandwich Karton (Graphicboard)": ("#e3d6bb", "foam"),
  53. "Schichtstoff (HPL)": ("#6a6a68", "gloss"),
  54. "HDF (Hochdichte Faserplatte)": ("#6e5c46", "matte"),
  55. "Graue Pappe": ("#9c9c96", "fibre"),
  56. "Büttenpapier 300g": ("#f2ece0", "fibre"),
  57. "POM Delrin": ("#eeeae2", "matte"),
  58. "Filz Polyester": ("#8f9296", "felt"),
  59. "Platane": ("#d9c3a3", "grain"),
  60. }
  61. # --------------------------------------------------------------- Commons fetch
  62. def imageinfo(titles):
  63. out = {}
  64. titles = list(titles)
  65. for i in range(0, len(titles), 25):
  66. chunk = titles[i:i + 25]
  67. for attempt in range(6):
  68. r = requests.get(API, headers=UA, params={
  69. "action": "query", "format": "json",
  70. "titles": "|".join("File:" + t for t in chunk),
  71. "prop": "imageinfo", "iiprop": "url|extmetadata|size|mime",
  72. "iiurlwidth": str(SIZE * 4)}, timeout=60)
  73. if r.status_code in (429, 503):
  74. time.sleep(3 * (attempt + 1)); continue
  75. r.raise_for_status(); break
  76. for p in r.json().get("query", {}).get("pages", {}).values():
  77. if "missing" in p:
  78. print(" MISSING: " + p["title"]); continue
  79. ii = p["imageinfo"][0]; md = ii.get("extmetadata", {})
  80. strip = lambda s: re.sub(r"<[^>]+>", "", s or "").strip()
  81. out[p["title"][5:]] = {
  82. "thumb": ii.get("thumburl") or ii["url"],
  83. "page": ii["descriptionurl"],
  84. "license": strip(md.get("LicenseShortName", {}).get("value")),
  85. "author": strip(md.get("Artist", {}).get("value"))[:80],
  86. }
  87. time.sleep(1.0)
  88. return out
  89. def square(im):
  90. im = im.convert("RGB")
  91. w, h = im.size
  92. s = min(w, h)
  93. # centre crop, but for very wide sample-strip images take the middle band
  94. im = im.crop(((w - s) // 2, (h - s) // 2, (w - s) // 2 + s, (h - s) // 2 + s))
  95. return im.resize((SIZE, SIZE), Image.LANCZOS)
  96. # --------------------------------------------------------------- swatches
  97. def hexrgb(h): return tuple(int(h[i:i+2], 16) for i in (1, 3, 5))
  98. def swatch(base, style, seed):
  99. rnd = random.Random(seed)
  100. r, g, b = hexrgb(base)
  101. im = Image.new("RGB", (SIZE, SIZE), (r, g, b))
  102. d = ImageDraw.Draw(im, "RGBA")
  103. def jitter(px, amp):
  104. pix = im.load()
  105. for y in range(SIZE):
  106. for x in range(SIZE):
  107. n = rnd.randint(-amp, amp)
  108. c = pix[x, y]
  109. pix[x, y] = tuple(max(0, min(255, v + n)) for v in c)
  110. if style == "fibre":
  111. jitter(im, 9)
  112. for _ in range(SIZE * 5):
  113. x, y = rnd.randrange(SIZE), rnd.randrange(SIZE)
  114. l = rnd.randint(3, 11); a = rnd.random() * math.pi
  115. d.line([x, y, x + l * math.cos(a), y + l * math.sin(a)],
  116. fill=(255, 255, 255, 40) if rnd.random() < .5 else (0, 0, 0, 22))
  117. im = im.filter(ImageFilter.SMOOTH)
  118. elif style == "weave":
  119. p = 3
  120. for y in range(0, SIZE, p):
  121. for x in range(0, SIZE, p):
  122. up = ((x // p) + (y // p)) % 2 == 0
  123. d.rectangle([x, y, x + p - 1, y + p - 1],
  124. fill=(255, 255, 255, 22) if up else (0, 0, 0, 20))
  125. jitter(im, 5)
  126. im = im.filter(ImageFilter.GaussianBlur(0.4))
  127. elif style == "foam":
  128. for _ in range(900):
  129. x, y = rnd.randrange(SIZE), rnd.randrange(SIZE); rr = rnd.randint(1, 3)
  130. d.ellipse([x - rr, y - rr, x + rr, y + rr], fill=(0, 0, 0, 14))
  131. d.ellipse([x - rr, y - rr, x + rr - 1, y + rr - 1], outline=(255, 255, 255, 40))
  132. im = im.filter(ImageFilter.SMOOTH)
  133. elif style == "brushed":
  134. for _ in range(SIZE * 14):
  135. y = rnd.randrange(SIZE); x = rnd.randrange(SIZE); l = rnd.randint(8, 46)
  136. a = rnd.randint(-20, 20)
  137. d.line([x, y, x + l, y], fill=(255, 255, 255, a) if a > 0 else (0, 0, 0, -a))
  138. im = im.filter(ImageFilter.GaussianBlur(0.3))
  139. elif style == "gloss":
  140. for y in range(SIZE):
  141. d.line([0, y, SIZE, y], fill=(255, 255, 255, int(46 * (1 - y / SIZE))))
  142. d.polygon([(0, SIZE), (SIZE * .75, 0), (SIZE, 0), (0, SIZE * .95)], fill=(255, 255, 255, 52))
  143. d.rectangle([0, 0, SIZE - 1, SIZE - 1], outline=(255, 255, 255, 120))
  144. elif style == "glass":
  145. for y in range(SIZE):
  146. d.line([0, y, SIZE, y], fill=(255, 255, 255, int(30 * (1 - y / SIZE))))
  147. jitter(im, 6)
  148. im = im.filter(ImageFilter.GaussianBlur(1.1))
  149. d = ImageDraw.Draw(im, "RGBA")
  150. d.polygon([(0, SIZE), (SIZE * .6, 0), (SIZE * .85, 0), (0, SIZE * .75)], fill=(255, 255, 255, 40))
  151. elif style == "film":
  152. for y in range(SIZE):
  153. d.line([0, y, SIZE, y], fill=(255, 255, 255, int(34 * abs(math.sin(y / 22.0)))))
  154. d.rectangle([0, 0, SIZE - 1, SIZE - 1], outline=(180, 190, 195, 160))
  155. elif style == "crumple":
  156. jitter(im, 12)
  157. for _ in range(60):
  158. x1, y1 = rnd.randrange(SIZE), rnd.randrange(SIZE)
  159. d.line([x1, y1, x1 + rnd.randint(-40, 40), y1 + rnd.randint(-40, 40)],
  160. fill=(255, 255, 255, 30), width=1)
  161. im = im.filter(ImageFilter.GaussianBlur(0.7))
  162. elif style == "felt":
  163. jitter(im, 10)
  164. for _ in range(SIZE * 22):
  165. x, y = rnd.randrange(SIZE), rnd.randrange(SIZE)
  166. l = rnd.randint(2, 6); a = rnd.random() * math.pi * 2
  167. d.line([x, y, x + l * math.cos(a), y + l * math.sin(a)],
  168. fill=(255, 255, 255, 26) if rnd.random() < .5 else (0, 0, 0, 24))
  169. im = im.filter(ImageFilter.GaussianBlur(0.5))
  170. elif style == "grain":
  171. pix = im.load()
  172. for x in range(SIZE):
  173. for y in range(SIZE):
  174. v = math.sin((x + 9 * math.sin(y / 34.0)) / 2.6) * 10 + rnd.randint(-6, 6)
  175. pix[x, y] = tuple(max(0, min(255, int(c + v))) for c in pix[x, y])
  176. d = ImageDraw.Draw(im, "RGBA")
  177. for _ in range(7):
  178. x = rnd.randrange(SIZE)
  179. d.line([x, 0, x + rnd.randint(-12, 12), SIZE], fill=(0, 0, 0, 26), width=rnd.randint(1, 2))
  180. im = im.filter(ImageFilter.SMOOTH)
  181. else: # matte
  182. jitter(im, 7)
  183. im = im.filter(ImageFilter.SMOOTH)
  184. return im
  185. # --------------------------------------------------------------- main
  186. if __name__ == "__main__":
  187. mats = []
  188. for fn in sorted(os.listdir(os.path.join(ROOT, "materials"))):
  189. if fn.endswith(".xml"):
  190. txt = open(os.path.join(ROOT, "materials", fn), encoding="utf-8").read()
  191. mats.append(re.search(r"<name>([^<]*)</name>", txt).group(1))
  192. info = imageinfo(set(PHOTO.values()))
  193. credits = []
  194. for m in mats:
  195. out = os.path.join(ROOT, "materials", tp(m) + ".png")
  196. title = PHOTO.get(m)
  197. if title and title in info:
  198. i = info[title]
  199. try:
  200. raw = requests.get(i["thumb"], headers=UA, timeout=60)
  201. raw.raise_for_status()
  202. square(Image.open(io.BytesIO(raw.content))).save(out, optimize=True)
  203. credits.append((m, "photo", title, i["license"], i["author"], i["page"]))
  204. print(" photo %-32s %s" % (m, title[:50]))
  205. time.sleep(0.5)
  206. continue
  207. except Exception as e:
  208. print(" FAILED %-32s %s (%s)" % (m, title[:40], e))
  209. base, style = SWATCH.get(m, ("#c9c4ba", "matte"))
  210. swatch(base, style, m).save(out, optimize=True)
  211. credits.append((m, "swatch", style, "generated", "", ""))
  212. print(" swatch %-32s %s" % (m, style))
  213. json.dump(credits, open("/tmp/claude-1000/-home-zoadian-projects-visicut-profiles/c951df34-51ea-44d2-be54-fcc330e0ab58/scratchpad/credits.json", "w"), indent=1, ensure_ascii=False)