
我正在進行個人開發的料理應用程式「CookForYou」。
在開發過程中,我注意到一件事:幾乎沒有系統地教導料理「擺盤」的內容。雖然食譜影片無數,烹飪影片也很多,但解釋「為什麼這種擺盤看起來更美味」的內容卻異常稀少。
專業廚師可以憑感覺來了解,但普通人往往止於「好像無法擺得很有格調...」。
因此,我決定製作一個可以學習擺盤基本概念的內容。不過,僅僅文字解釋是很難被閱讀的。此外,委託專業人士製作漫畫的費用通常高達幾十萬元。因此,我想到了一個 「完全由AI製作漫畫的管道」。
在2024年底至2025年間,Google的圖像生成AI Nano Banana Pro(API名稱: gemini-3-pro-image-preview)的能力得到了顯著提升。
特別重要的是 「提供參考圖片可以保持角色的一致性」 這一功能。這使得可以生成同一角色出現在連貫的漫畫頁面中。
重點:先生成設定畫(角色和背景),然後將其作為參考圖片傳遞給Nano Banana Pro,以保持整體漫畫的一致性。
為什麼需要角色設計?
Nano Banana Pro在收到參考圖片後,可以在保持該角色和背景的情況下生成新的場景。這意味著,如果先製作「設定畫」,就可以在整個漫畫中保持角色的一致性。
反過來說,如果每次都沒有設定畫,而只是用提示詞指定「黑髮的男性」,那麼每頁的臉型可能都會變化。
因此,首先我與Claude進行了角色設定的討論。設定了一對像「四葉妹妹」那樣的日常風格角色,一位是烹飪新手的男友,另一位是廚藝高超的女友。
優太(25歲,男性)
美奈(24歲,女性)
根據角色設定,使用Nano Banana Pro生成了設定畫。
簡單漫畫角色設計圖,風格為東浩紀的「四葉妹妹」,25歲的年輕日本男性,短黑髮,圓潤友善的眼睛,柔和的臉部特徵,穿著素灰色T恤和黑色運動褲,站姿並附帶三種表情變化(正常、驚訝帶有汗珠、快樂帶有閃亮效果),全身正中間,白色背景,干淨的線條藝術,平面顏色,溫暖且親和的表情,漫畫插圖,柔和色調


背景也同樣生成。


與Claude協商後,決定了以下的CSV格式。
頁面,框,框大小,場景描述,角色,對話,漫畫部分提示,實拍料理提示,備註
1,1,大,優太把和食擺盤成西餐風格,優太,我做了肉燉蘿蔔!放在大盤子上了!,"被攝體:優太站在餐桌前,手拿著一個白色的大圓盤,臉上帶著得意的笑容。構圖:桌子對面優太的上半身。地點:1LDK公寓的餐廳,晚上。風格:四葉妹妹風,彩色,全色彩。","肉燉蘿蔔堆在白色圓盤上,呈現出西餐風格,讓人感到不和諧",導入
1,2,中,美奈微妙的反應,美奈,看起來好像很好吃...但有點不像和食,“被攝體:美奈坐在桌子前,看著盤子,歪著頭。構圖:半身像。風格:四葉妹妹風,彩色。”,,
重點在於「漫畫部分提示」和「實拍料理提示」的分開。我原本打算提供實拍菜餚的照片來做組合,但這次請Nano Banana Pro製作漫畫風格的料理。
調用Nano Banana Pro API的基本模組。
"""
Nano Banana Pro 圖像生成(支持多圖)
用法:
python -m tools.generate_image "貓的插圖"
python -m tools.generate_image "貓的插圖" -o cat.png
python -m tools.generate_image "貓" "狗" "鳥" -o cat.png dog.png bird.png
python -m tools.generate_image "風景" --aspect 16:9 --size 2K
"""
import argparse
import sys
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from io import BytesIO
from PIL import Image
from google.genai import types
from google import genai
from retry import retry
def load_and_resize_images(image_paths: list[str | Path], size: tuple[int, int] = (200, 200)) -> list[bytes]:
"""讀取圖像並調整大小,返回字節數據列表
參數:
image_paths: 圖像文件的路徑列表
size: 調整後的大小 (width, height)
返回:
調整大小的圖像的字節數據列表
"""
images = []
for path in image_paths:
img = Image.open(path)
img_resized = img.resize(size, Image.Resampling.LANCZOS)
buffer = BytesIO()
img_resized.save(buffer, format="PNG")
images.append(buffer.getvalue())
return images
@retry(
exceptions=Exception,
tries=5,
delay=2,
backoff=2,
jitter=(0, 1),
)
def generate_image(
prompt: str,
aspect_ratio: str = "1:1",
image_size: str = "1K",
model: str = "gemini-3-pro-image-preview",
input_images: list[bytes] | None = None,
) -> dict | None:
"""在Nano Banana Pro中生成圖像
參數:
prompt: 生成提示
aspect_ratio: 圖像比例 ("1:1", "16:9", "9:16", "3:2", "4:3")
image_size: 解析度 ("1K", "2K", "4K")
model: 使用的模型名稱
input_images: 輸入圖像的字節數據列表(可選)
返回:
{"data": bytes, "text": str | None, "prompt": str} 失敗時返回 None
"""
client = genai.Client(vertexai=True, project="your-project", location="global")
contents = []
if input_images:
for img_data in input_images:
contents.append(types.Part.from_bytes(data=img_data, mime_type="image/png"))
contents.append(prompt)
response = client.models.generate_content(
model=model,
contents=contents,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
image_size=image_size,
),
),
)
result = {"data": None, "text": None, "prompt": prompt}
for part in response.candidates[0].content.parts:
if hasattr(part, "inline_data") and part.inline_data:
result["data"] = part.inline_data.data
elif hasattr(part, "text") and part.text:
result["text"] = part.text
if not result["data"]:
raise ValueError("生成圖像失敗:未返回圖像數據")
return result
def generate_images(
prompts: list[str],
aspect_ratio: str = "1:1",
image_size: str = "1K",
model: str = "gemini-3-pro-image-preview",
max_workers: int = 3,
) -> list[dict]:
"""同時生成多個圖像
參數:
prompts: 生成提示的列表
aspect_ratio: 圖像比例
image_size: 解析度
model: 使用的模型名稱
max_workers: 並行執行的數量
返回:
生成結果的列表 [{ "data": bytes, "text": str | None, "prompt": str}, ...]
"""
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_prompt = {
executor.submit(generate_image, prompt=prompt, aspect_ratio=aspect_ratio, image_size=image_size, model=model): prompt
for prompt in prompts
}
for future in as_completed(future_to_prompt):
prompt = future_to_prompt[future]
try:
result = future.result()
results.append(result)
except Exception as e:
print(f"生成圖像時發生錯誤 '{prompt}': {e}", file=sys.stderr)
results.append({"data": None, "text": None, "prompt": prompt, "error": str(e)})
return results
def save_image(data: bytes, output_path: str) -> str:
"""保存圖像"""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_bytes(data)
return output_path
讀取CSV並生成漫畫頁面的主腳本。
"""
漫畫生成主腳本
從data/base的圖像中讀取,調整為200x200大小,
並基於CSV文件逐頁生成漫畫
"""
import argparse
import csv
import sys
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from tqdm import tqdm
from tools.generate_image import generate_image, load_and_resize_images
PROMPT_PREFIX = """請根據這個角色和房子創建漫畫
【重要!!】
時間序列必須順序進行:右上->左上->右下->左下
不要出現左下 -> 右下的情況!!
"""
def load_csv(csv_path: Path) -> dict[int, list[dict]]:
"""讀取CSV文件並按頁面分組
參數:
csv_path: CSV文件的路徑
返回:
以頁面編號為鍵,頁面信息列表為值的字典
"""
pages = defaultdict(list)
with open(csv_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
page_num = int(row["頁面"].strip())
pages[page_num].append(row)
return dict(sorted(pages.items()))
def build_page_prompt(page_data: list[dict]) -> str:
"""根據頁面數據建立提示
"""
prompt_parts = []
for i, panel in enumerate(page_data, 1):
panel_size = panel.get("框大小", "")
scene = panel.get("場景描述", "")
character = panel.get("角色", "")
dialogue = panel.get("對話", "")
manga_prompt = panel.get("漫畫部分提示", "")
prompt_parts.append(f"框{i}({panel_size})")
if scene:
prompt_parts.append(f"【場景】{scene}")
if character:
prompt_parts.append(f"【{character}】")
if manga_prompt:
prompt_parts.append(f"【提示】{manga_prompt}")
if dialogue:
prompt_parts.append(f"【對話】{dialogue}")
prompt_parts.append("")
return "\n".join(prompt_parts)
def main():
parser = argparse.ArgumentParser(description="從CSV文件生成漫畫")
parser.add_argument("csv_file", type=Path, help="CSV文件的路徑")
args = parser.parse_args()
csv_path = args.csv_file
base_dir = Path("data/base")
print(f"正在從 {csv_path} 加載CSV...")
pages = load_csv(csv_path)
print(f"找到 {len(pages)} 頁")
# 讀取參考圖片(角色設定畫、背景等)
image_files = sorted(base_dir.glob("*.png")) + sorted(base_dir.glob("*.jpg"))
print(f"加載 {len(image_files)} 張參考圖片...")
resized_images = load_and_resize_images(image_files, size=(200, 200))
output_dir = Path("output") / csv_path.stem
output_dir.mkdir(parents=True, exist_ok=True)
def generate_page(page_num: int, page_data: list[dict]) -> tuple[int, bool, str]:
"""生成頁面的函數"""
output_file = output_dir / f"{page_num}.png"
if output_file.exists():
return (page_num, False, f"頁面 {page_num}: 已存在,跳過...")
page_prompt = build_page_prompt(page_data)
full_prompt = PROMPT_PREFIX + page_prompt
result = generate_image(
prompt=full_prompt,
aspect_ratio="9:16",
image_size="2K",
input_images=resized_images,
)
output_file.write_bytes(result["data"])
return (page_num, True, f"頁面 {page_num} 已保存到 {output_file}")
# 並行生成
pages_to_generate = [
(page_num, page_data)
for page_num, page_data in pages.items()
if not (output_dir / f"{page_num}.png").exists()
]
print(f"正在並行生成 {len(pages_to_generate)} 頁...")
with ThreadPoolExecutor(max_workers=3) as executor:
future_to_page = {
executor.submit(generate_page, page_num, page_data): page_num
for page_num, page_data in pages_to_generate
}
with tqdm(total=len(pages_to_generate), desc="生成頁面") as pbar:
for future in as_completed(future_to_page):
page_num, success, message = future.result()
pbar.update(1)
print(f"完成!輸出: {output_dir}")
if __name__ == "__main__":
main()
將生成的圖片轉換為WebP格式以優化大小。
"""將PNG/JPG轉換為WebP以減小文件大小
"""
from pathlib import Path
from PIL import Image
from tqdm import tqdm
def convert_to_webp(input_path: Path, output_path: Path, quality: int = 85):
"""將圖像轉換為WebP格式"""
with Image.open(input_path) as img:
if img.mode == 'RGBA':
img.save(output_path, 'WEBP', quality=quality, method=6)
else:
img = img.convert('RGB')
img.save(output_path, 'WEBP', quality=quality, method=6)
def main():
finished_dir = Path("output")
compressed_dir = Path("compressed")
image_extensions = {'.png', '.jpg', '.jpeg', '.PNG', '.JPG', '.JPEG'}
all_images = []
for subdir in finished_dir.iterdir():
if subdir.is_dir():
for img_file in subdir.iterdir():
if img_file.suffix in image_extensions:
all_images.append((subdir, img_file))
print(f"找到 {len(all_images)} 張圖片要轉換")
for subdir, img_file in tqdm(all_images, desc="轉換為WebP"):
relative_subdir = subdir.relative_to(finished_dir)
output_subdir = compressed_dir / relative_subdir
output_subdir.mkdir(parents=True, exist_ok=True)
output_file = output_subdir / f"{img_file.stem}.webp"
if not output_file.exists():
convert_to_webp(img_file, output_file)
print(f"輸出: {compressed_dir}")
if __name__ == "__main__":
main()
project/
├── data/
│ └── base/ # 參考圖片(角色設定畫、背景)
│ ├── yuta.png
│ ├── mina.png
│ ├── kitchen.png
│ └── dining.png
├── output/
│ └── chapter1/ # 生成的漫畫頁面
│ ├── 1.png
│ ├── 2.png
│ └── ...
├── compressed/
│ └── chapter1/ # 壓縮為webp格式的漫畫頁面
│ ├── 1.png
│ ├── 2.png
│ └── ...
├── main.py
└── tools/
└── generate_image.py


Nano Banana Pro的圖像生成非常出色,但在日文文本的生成上有困難。


由於「始」旁邊有不明的文字,因此我在Canva中進行了修正。
這個步驟是手動進行的,但每頁大約需要2-3分鐘即可完成。
最後,使用 convert_to_webp.py 進行圖片大小的優化,然後在網路上公開。
python convert_to_webp.py
透過WebP轉換,圖像大小減少了約90%(約6MB → 約600KB)。
✅ 角色一致性:傳遞參考圖片後,頁面間角色的外觀幾乎統一
✅ CSV驅動的效率提升:通過CSV管理劇本,修正和添加變得更便捷
✅ 並行生成:可以同時生成多個頁面,因此1個章節(5-10頁)在幾分鐘內即可完成
⚠️ 日文文本:Nano Banana Pro對於日文的文字生成表現較差,必須手動在Canva中修正
⚠️ 框架控制:遵循「右上→左上→右下→左下」的順序主導指令有時會崩潰。即使在提示中強調,有時仍然失序
⚠️ 料理描寫:雖然可以繪製漫畫風格的料理,但其「美味感」不如實拍菜餚








第二章(色彩篇)的一部分已在Qiita上公開,但其餘內容可以在LINE Mini App上完全免費閱讀。
📖 擺盤的七大支柱 - 完整版
https://miniapp.line.me/2008548551-A86l0jJv/manga/how-to-plate
希望對對料理擺盤有興趣的朋友和想利用AI製作漫畫的人有所幫助!
我作為自由工作者在AI/LLM相關的開發上工作。
擅長領域:
成果:
無論是「能不能做這種東西?」這種程度的諮詢都可以,
隨時歡迎私訊我。
隨時歡迎洽詢!💪
(如果能通過多個平台聯繫我會更可靠)
標籤: #Claude #NanoBananaPro #Gemini #生成AI #Python #個人開發 #漫畫 #圖像生成