| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- """
- 把 result.json 渲染成拼图: 每张最多 12 格, 按总数均匀分配。
- 每格 = 完整(contain, 不裁剪)封面 + 序号徽章 + 标题。
- 输出到 collages/page-NN.jpg + INDEX.md。
- """
- import json
- import math
- from pathlib import Path
- from PIL import Image, ImageDraw, ImageFont
- OUT_DIR = Path('/Users/sunlit/Profile/analysis/ai-portrait-realism')
- RESULT = OUT_DIR / 'result.json'
- COLLAGE_DIR = OUT_DIR / 'collages'
- COLLAGE_DIR.mkdir(exist_ok=True)
- # ── 视觉参数 ────────────────────────
- MAX_PER_PAGE = 12
- COLS = 4 # 4 列网格 (4x3 = 12 max)
- TILE_IMG_SIZE = 380 # 每格图片区边长 (正方形 contain 容器)
- TILE_TEXT_HEIGHT = 96 # 每格文字区高度
- PADDING = 16 # 格间距
- PAGE_HEADER_H = 64 # 页眉高度
- BG_COLOR = (245, 245, 247)
- TEXT_CARD_BG = (255, 255, 255)
- TEXT_COLOR = (29, 29, 31)
- SUBTLE_COLOR = (134, 134, 139)
- INDEX_BG = (220, 60, 60)
- INDEX_FG = (255, 255, 255)
- LETTERBOX_BG = (29, 29, 31) # 与前端 contain 背景一致
- CATEGORY_COLOR = {
- 'realism': (52, 199, 89),
- 'studio': (0, 113, 227),
- 'portrait_art': (175, 82, 222),
- }
- FONT_CANDIDATES = [
- '/System/Library/Fonts/Hiragino Sans GB.ttc',
- '/System/Library/Fonts/STHeiti Medium.ttc',
- '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
- ]
- def load_font(size):
- for path in FONT_CANDIDATES:
- try:
- return ImageFont.truetype(path, size)
- except (OSError, IOError):
- continue
- return ImageFont.load_default()
- def split_evenly(total, max_per):
- """27 → [9,9,9]; 24 → [12,12]; 13 → [7,6]"""
- n_pages = math.ceil(total / max_per) or 1
- base = total // n_pages
- extra = total % n_pages
- return [base + 1] * extra + [base] * (n_pages - extra)
- def fit_image(src_path, target_w, target_h):
- """contain 缩放, 居中, letterbox 填充 — 完整显示, 不裁剪。"""
- try:
- img = Image.open(src_path).convert('RGB')
- except Exception:
- return None
- w, h = img.size
- scale = min(target_w / w, target_h / h)
- new_w = max(1, int(w * scale))
- new_h = max(1, int(h * scale))
- img = img.resize((new_w, new_h), Image.LANCZOS)
- canvas = Image.new('RGB', (target_w, target_h), LETTERBOX_BG)
- canvas.paste(img, ((target_w - new_w) // 2, (target_h - new_h) // 2))
- return canvas
- def wrap_chinese(draw, text, font, max_width):
- """字符级换行, 中英文均适用。"""
- lines, cur = [], ''
- for ch in text:
- test = cur + ch
- bbox = draw.textbbox((0, 0), test, font=font)
- if (bbox[2] - bbox[0]) > max_width and cur:
- lines.append(cur)
- cur = ch
- else:
- cur = test
- if cur:
- lines.append(cur)
- return lines
- def make_tile(item):
- img_w = TILE_IMG_SIZE
- text_h = TILE_TEXT_HEIGHT
- tile = Image.new('RGB', (img_w, img_w + text_h), BG_COLOR)
- # 上半: 封面 (contain, 完整)
- cover_path = OUT_DIR / item['cover'] if item.get('cover') else None
- img_canvas = fit_image(cover_path, img_w, img_w) if cover_path and cover_path.exists() else None
- if img_canvas is None:
- img_canvas = Image.new('RGB', (img_w, img_w), (200, 200, 205))
- d = ImageDraw.Draw(img_canvas)
- f = load_font(20)
- msg = '无封面'
- bbox = d.textbbox((0, 0), msg, font=f)
- bw, bh = bbox[2] - bbox[0], bbox[3] - bbox[1]
- d.text(((img_w - bw) // 2, (img_w - bh) // 2), msg, fill=(110, 110, 115), font=f)
- tile.paste(img_canvas, (0, 0))
- # 下半: 文字卡 (序号徽章 + 标题 + 分类点)
- text_canvas = Image.new('RGB', (img_w, text_h), TEXT_CARD_BG)
- draw = ImageDraw.Draw(text_canvas)
- # 序号徽章 (左上)
- pad = 12
- badge_h = 30
- badge_w = 56
- draw.rounded_rectangle((pad, pad, pad + badge_w, pad + badge_h),
- radius=8, fill=INDEX_BG)
- badge_font = load_font(17)
- badge_text = f'#{item["index"]:02d}'
- bbox = draw.textbbox((0, 0), badge_text, font=badge_font)
- bw = bbox[2] - bbox[0]
- bh = bbox[3] - bbox[1]
- draw.text((pad + (badge_w - bw) // 2, pad + (badge_h - bh) // 2 - 2),
- badge_text, fill=INDEX_FG, font=badge_font)
- # 分类小圆点 (右上)
- cat_color = CATEGORY_COLOR.get(item.get('category', ''), (180, 180, 180))
- dot_r = 6
- dot_x = img_w - pad - dot_r * 2
- dot_y = pad + badge_h // 2 - dot_r
- draw.ellipse((dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2), fill=cat_color)
- # 标题 (徽章下方, 最多 2 行)
- title_font = load_font(15)
- title_x = pad
- title_y = pad + badge_h + 8
- title_max_w = img_w - 2 * pad
- title_lines = wrap_chinese(draw, item.get('title', ''), title_font, title_max_w)
- if len(title_lines) > 2:
- # 截到 2 行 + 省略号
- last = title_lines[1]
- while last:
- test = last[:-1] + '…'
- bbox = draw.textbbox((0, 0), test, font=title_font)
- if (bbox[2] - bbox[0]) <= title_max_w:
- title_lines = [title_lines[0], test]
- break
- last = last[:-1]
- for i, line in enumerate(title_lines[:2]):
- draw.text((title_x, title_y + i * 22), line, fill=TEXT_COLOR, font=title_font)
- tile.paste(text_canvas, (0, img_w))
- return tile
- def make_page(items, page_num, total_pages):
- cols = min(COLS, len(items))
- rows = math.ceil(len(items) / cols)
- tile_w = TILE_IMG_SIZE
- tile_h = TILE_IMG_SIZE + TILE_TEXT_HEIGHT
- page_w = cols * tile_w + (cols + 1) * PADDING
- page_h = PAGE_HEADER_H + rows * tile_h + (rows + 1) * PADDING
- page = Image.new('RGB', (page_w, page_h), BG_COLOR)
- draw = ImageDraw.Draw(page)
- header_font = load_font(22)
- sub_font = load_font(13)
- title = f'AI 写真调研 — 第 {page_num} / {total_pages} 页'
- draw.text((PADDING * 2, 18), title, fill=TEXT_COLOR, font=header_font)
- sub = f'{len(items)} 条 (#{items[0]["index"]:02d} – #{items[-1]["index"]:02d})'
- draw.text((PADDING * 2, 44), sub, fill=SUBTLE_COLOR, font=sub_font)
- for i, item in enumerate(items):
- r, c = divmod(i, cols)
- x = PADDING + c * (tile_w + PADDING)
- y = PAGE_HEADER_H + PADDING + r * (tile_h + PADDING)
- tile = make_tile(item)
- page.paste(tile, (x, y))
- return page
- def main():
- items = json.loads(RESULT.read_text(encoding='utf-8'))
- sizes = split_evenly(len(items), MAX_PER_PAGE)
- print(f'Total {len(items)} items → {len(sizes)} pages ({sizes})')
- pages_meta = []
- cursor = 0
- for page_num, n in enumerate(sizes, 1):
- page_items = items[cursor:cursor + n]
- cursor += n
- page_img = make_page(page_items, page_num, len(sizes))
- out_path = COLLAGE_DIR / f'page-{page_num:02d}.jpg'
- page_img.save(out_path, 'JPEG', quality=88, optimize=True)
- pages_meta.append({
- 'page': page_num,
- 'file': out_path.name,
- 'size': page_img.size,
- 'indices': [it['index'] for it in page_items],
- })
- print(f' [OK] {out_path.name} {page_img.size[0]}×{page_img.size[1]}px ({len(page_items)} items)')
- # 索引
- md = ['# 拼图索引', '', f'总 {len(items)} 条 / {len(sizes)} 张拼图 / 每张最多 {MAX_PER_PAGE} 格', '']
- for m in pages_meta:
- md.append(f'## {m["file"]}')
- md.append(f'- 尺寸: {m["size"][0]}×{m["size"][1]}px')
- md.append(f'- 条目: {", ".join(f"#{i:02d}" for i in m["indices"])}')
- md.append('')
- (COLLAGE_DIR / 'INDEX.md').write_text('\n'.join(md), encoding='utf-8')
- print(f'\n=== {len(sizes)} pages → {COLLAGE_DIR}')
- if __name__ == '__main__':
- main()
|