collage.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. """
  2. 把 result.json 渲染成拼图: 每张最多 12 格, 按总数均匀分配。
  3. 每格 = 完整(contain, 不裁剪)封面 + 序号徽章 + 标题。
  4. 输出到 collages/page-NN.jpg + INDEX.md。
  5. """
  6. import json
  7. import math
  8. from pathlib import Path
  9. from PIL import Image, ImageDraw, ImageFont
  10. OUT_DIR = Path('/Users/sunlit/Profile/analysis/ai-portrait-realism')
  11. RESULT = OUT_DIR / 'result.json'
  12. COLLAGE_DIR = OUT_DIR / 'collages'
  13. COLLAGE_DIR.mkdir(exist_ok=True)
  14. # ── 视觉参数 ────────────────────────
  15. MAX_PER_PAGE = 12
  16. COLS = 4 # 4 列网格 (4x3 = 12 max)
  17. TILE_IMG_SIZE = 380 # 每格图片区边长 (正方形 contain 容器)
  18. TILE_TEXT_HEIGHT = 96 # 每格文字区高度
  19. PADDING = 16 # 格间距
  20. PAGE_HEADER_H = 64 # 页眉高度
  21. BG_COLOR = (245, 245, 247)
  22. TEXT_CARD_BG = (255, 255, 255)
  23. TEXT_COLOR = (29, 29, 31)
  24. SUBTLE_COLOR = (134, 134, 139)
  25. INDEX_BG = (220, 60, 60)
  26. INDEX_FG = (255, 255, 255)
  27. LETTERBOX_BG = (29, 29, 31) # 与前端 contain 背景一致
  28. CATEGORY_COLOR = {
  29. 'realism': (52, 199, 89),
  30. 'studio': (0, 113, 227),
  31. 'portrait_art': (175, 82, 222),
  32. }
  33. FONT_CANDIDATES = [
  34. '/System/Library/Fonts/Hiragino Sans GB.ttc',
  35. '/System/Library/Fonts/STHeiti Medium.ttc',
  36. '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
  37. ]
  38. def load_font(size):
  39. for path in FONT_CANDIDATES:
  40. try:
  41. return ImageFont.truetype(path, size)
  42. except (OSError, IOError):
  43. continue
  44. return ImageFont.load_default()
  45. def split_evenly(total, max_per):
  46. """27 → [9,9,9]; 24 → [12,12]; 13 → [7,6]"""
  47. n_pages = math.ceil(total / max_per) or 1
  48. base = total // n_pages
  49. extra = total % n_pages
  50. return [base + 1] * extra + [base] * (n_pages - extra)
  51. def fit_image(src_path, target_w, target_h):
  52. """contain 缩放, 居中, letterbox 填充 — 完整显示, 不裁剪。"""
  53. try:
  54. img = Image.open(src_path).convert('RGB')
  55. except Exception:
  56. return None
  57. w, h = img.size
  58. scale = min(target_w / w, target_h / h)
  59. new_w = max(1, int(w * scale))
  60. new_h = max(1, int(h * scale))
  61. img = img.resize((new_w, new_h), Image.LANCZOS)
  62. canvas = Image.new('RGB', (target_w, target_h), LETTERBOX_BG)
  63. canvas.paste(img, ((target_w - new_w) // 2, (target_h - new_h) // 2))
  64. return canvas
  65. def wrap_chinese(draw, text, font, max_width):
  66. """字符级换行, 中英文均适用。"""
  67. lines, cur = [], ''
  68. for ch in text:
  69. test = cur + ch
  70. bbox = draw.textbbox((0, 0), test, font=font)
  71. if (bbox[2] - bbox[0]) > max_width and cur:
  72. lines.append(cur)
  73. cur = ch
  74. else:
  75. cur = test
  76. if cur:
  77. lines.append(cur)
  78. return lines
  79. def make_tile(item):
  80. img_w = TILE_IMG_SIZE
  81. text_h = TILE_TEXT_HEIGHT
  82. tile = Image.new('RGB', (img_w, img_w + text_h), BG_COLOR)
  83. # 上半: 封面 (contain, 完整)
  84. cover_path = OUT_DIR / item['cover'] if item.get('cover') else None
  85. img_canvas = fit_image(cover_path, img_w, img_w) if cover_path and cover_path.exists() else None
  86. if img_canvas is None:
  87. img_canvas = Image.new('RGB', (img_w, img_w), (200, 200, 205))
  88. d = ImageDraw.Draw(img_canvas)
  89. f = load_font(20)
  90. msg = '无封面'
  91. bbox = d.textbbox((0, 0), msg, font=f)
  92. bw, bh = bbox[2] - bbox[0], bbox[3] - bbox[1]
  93. d.text(((img_w - bw) // 2, (img_w - bh) // 2), msg, fill=(110, 110, 115), font=f)
  94. tile.paste(img_canvas, (0, 0))
  95. # 下半: 文字卡 (序号徽章 + 标题 + 分类点)
  96. text_canvas = Image.new('RGB', (img_w, text_h), TEXT_CARD_BG)
  97. draw = ImageDraw.Draw(text_canvas)
  98. # 序号徽章 (左上)
  99. pad = 12
  100. badge_h = 30
  101. badge_w = 56
  102. draw.rounded_rectangle((pad, pad, pad + badge_w, pad + badge_h),
  103. radius=8, fill=INDEX_BG)
  104. badge_font = load_font(17)
  105. badge_text = f'#{item["index"]:02d}'
  106. bbox = draw.textbbox((0, 0), badge_text, font=badge_font)
  107. bw = bbox[2] - bbox[0]
  108. bh = bbox[3] - bbox[1]
  109. draw.text((pad + (badge_w - bw) // 2, pad + (badge_h - bh) // 2 - 2),
  110. badge_text, fill=INDEX_FG, font=badge_font)
  111. # 分类小圆点 (右上)
  112. cat_color = CATEGORY_COLOR.get(item.get('category', ''), (180, 180, 180))
  113. dot_r = 6
  114. dot_x = img_w - pad - dot_r * 2
  115. dot_y = pad + badge_h // 2 - dot_r
  116. draw.ellipse((dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2), fill=cat_color)
  117. # 标题 (徽章下方, 最多 2 行)
  118. title_font = load_font(15)
  119. title_x = pad
  120. title_y = pad + badge_h + 8
  121. title_max_w = img_w - 2 * pad
  122. title_lines = wrap_chinese(draw, item.get('title', ''), title_font, title_max_w)
  123. if len(title_lines) > 2:
  124. # 截到 2 行 + 省略号
  125. last = title_lines[1]
  126. while last:
  127. test = last[:-1] + '…'
  128. bbox = draw.textbbox((0, 0), test, font=title_font)
  129. if (bbox[2] - bbox[0]) <= title_max_w:
  130. title_lines = [title_lines[0], test]
  131. break
  132. last = last[:-1]
  133. for i, line in enumerate(title_lines[:2]):
  134. draw.text((title_x, title_y + i * 22), line, fill=TEXT_COLOR, font=title_font)
  135. tile.paste(text_canvas, (0, img_w))
  136. return tile
  137. def make_page(items, page_num, total_pages):
  138. cols = min(COLS, len(items))
  139. rows = math.ceil(len(items) / cols)
  140. tile_w = TILE_IMG_SIZE
  141. tile_h = TILE_IMG_SIZE + TILE_TEXT_HEIGHT
  142. page_w = cols * tile_w + (cols + 1) * PADDING
  143. page_h = PAGE_HEADER_H + rows * tile_h + (rows + 1) * PADDING
  144. page = Image.new('RGB', (page_w, page_h), BG_COLOR)
  145. draw = ImageDraw.Draw(page)
  146. header_font = load_font(22)
  147. sub_font = load_font(13)
  148. title = f'AI 写真调研 — 第 {page_num} / {total_pages} 页'
  149. draw.text((PADDING * 2, 18), title, fill=TEXT_COLOR, font=header_font)
  150. sub = f'{len(items)} 条 (#{items[0]["index"]:02d} – #{items[-1]["index"]:02d})'
  151. draw.text((PADDING * 2, 44), sub, fill=SUBTLE_COLOR, font=sub_font)
  152. for i, item in enumerate(items):
  153. r, c = divmod(i, cols)
  154. x = PADDING + c * (tile_w + PADDING)
  155. y = PAGE_HEADER_H + PADDING + r * (tile_h + PADDING)
  156. tile = make_tile(item)
  157. page.paste(tile, (x, y))
  158. return page
  159. def main():
  160. items = json.loads(RESULT.read_text(encoding='utf-8'))
  161. sizes = split_evenly(len(items), MAX_PER_PAGE)
  162. print(f'Total {len(items)} items → {len(sizes)} pages ({sizes})')
  163. pages_meta = []
  164. cursor = 0
  165. for page_num, n in enumerate(sizes, 1):
  166. page_items = items[cursor:cursor + n]
  167. cursor += n
  168. page_img = make_page(page_items, page_num, len(sizes))
  169. out_path = COLLAGE_DIR / f'page-{page_num:02d}.jpg'
  170. page_img.save(out_path, 'JPEG', quality=88, optimize=True)
  171. pages_meta.append({
  172. 'page': page_num,
  173. 'file': out_path.name,
  174. 'size': page_img.size,
  175. 'indices': [it['index'] for it in page_items],
  176. })
  177. print(f' [OK] {out_path.name} {page_img.size[0]}×{page_img.size[1]}px ({len(page_items)} items)')
  178. # 索引
  179. md = ['# 拼图索引', '', f'总 {len(items)} 条 / {len(sizes)} 张拼图 / 每张最多 {MAX_PER_PAGE} 格', '']
  180. for m in pages_meta:
  181. md.append(f'## {m["file"]}')
  182. md.append(f'- 尺寸: {m["size"][0]}×{m["size"][1]}px')
  183. md.append(f'- 条目: {", ".join(f"#{i:02d}" for i in m["indices"])}')
  184. md.append('')
  185. (COLLAGE_DIR / 'INDEX.md').write_text('\n'.join(md), encoding='utf-8')
  186. print(f'\n=== {len(sizes)} pages → {COLLAGE_DIR}')
  187. if __name__ == '__main__':
  188. main()