プロンプト維持してリサイズ、画像連結するスクリプト

としあき謹製
png_merge_with_prompts.py というファイル名で保存
必要なら pip install pillow
実行は input_pngs フォルダにpngを入れてから python png_merge_with_prompts.py
プロンプト維持してリサイズ、画像連結されて merged_small.png に保存される
原寸は merged.png 、ログは png_merge_with_prompts.log

import sys
import math
import datetime
import argparse
from typing import List, Tuple, Optional
from pathlib import Path
from PIL import Image, PngImagePlugin

def main() -> None:
    args = parse_args()
    log_message(f"main: 開始 input={args.input}, output={args.output}")
    input_folder = Path(args.input)
    validate_input_folder(input_folder)
    image_paths = get_image_paths(input_folder)
    prompts = collect_prompts(image_paths)
    try:
        process_images(image_paths, args.output, prompts)
    except (OSError, IOError, ValueError, RuntimeError) as e:
        handle_error(e)

def log_message(message: str) -> None:
    LOG_FILE = Path(__file__).with_suffix('.log').name
    timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(f"[{timestamp}] {message}\n")

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="画像を連結し、プロンプトメタデータを埋め込む")
    parser.add_argument("--input", type=str, default="input_pngs", help="入力画像フォルダ")
    parser.add_argument("--output", type=str, default="merged.png", help="出力画像ファイル名")
    return parser.parse_args()

def validate_input_folder(input_folder: Path) -> None:
    if not input_folder.exists():
        log_message(f"Error: 入力フォルダ {input_folder} が存在しません。")
        print(f"Error: 入力フォルダ {input_folder} が存在しません。", file=sys.stderr)
        sys.exit(1)

def get_image_paths(input_folder: Path) -> List[Path]:
    image_paths = sorted(list(input_folder.glob("*.png")))
    if len(image_paths) == 0:
        log_message(f"Error: フォルダ {input_folder} に画像が見つかりません。")
        print(f"Error: フォルダ {input_folder} に画像が見つかりません。", file=sys.stderr)
        sys.exit(1)

    log_message(f"{len(image_paths)}枚の画像が見つかりました。")
    return image_paths

def save_image_with_metadata(image: Image.Image, output_path: Path, prompts: List[str]) -> None:
    """画像を保存し、メタデータを埋め込む"""
    combined_prompt = format_prompts(prompts)
    meta = PngImagePlugin.PngInfo()
    meta.add_text("parameters", combined_prompt)

    try:
        print(f"画像ファイルに書き込み中... ({output_path.name})")
        image.save(output_path, pnginfo=meta)
        log_message(f"画像保存完了(メタデータ付き): {output_path}")
    except (OSError, IOError, ValueError) as e:
        log_message(f"画像保存失敗: {output_path} ({e})")
        raise RuntimeError(f"画像保存失敗: {output_path} ({e})") from e

def process_images(image_paths: List[Path], output: str, prompts: List[str]) -> None:
    """画像処理のメインフロー"""
    # 画像を連結
    grid_size = determine_grid_size(len(image_paths))
    combined = combine_images(image_paths, grid_size)
    output_path = Path(output).absolute()

    # 原寸版を保存
    save_image_with_metadata(combined, output_path, prompts)

    # 縮小版を作成・保存(連結前の画像サイズに合わせる)
    resized_combined = create_resized_to_original_size(image_paths, combined)
    resized_output_path = output_path.with_stem(output_path.stem + "_small")
    save_image_with_metadata(resized_combined, resized_output_path, prompts)

    # リソース解放
    combined.close()
    resized_combined.close()

    log_message(f"完了: 原寸版 {output_path}, 縮小版 {resized_output_path}{len(image_paths)}つのプロンプトを埋め込み済み")

def handle_error(e: Exception) -> None:
    log_message(f"エラー: {e}")
    print(f"エラー: {e}", file=sys.stderr)
    sys.exit(1)

def extract_prompt_metadata(png_file: Path) -> Optional[str]:
    """
    Pillowでparametersテキストチャンクを抽出
    """
    try:
        with Image.open(png_file) as img:
            return img.info.get('parameters')
    except (OSError, IOError) as e:
        log_message(f"メタデータ取得失敗: {png_file} ({e})")
        print(f"メタデータ取得失敗: {png_file} ({e})", file=sys.stderr)
        return None

def get_image_size(img: Image.Image) -> Tuple[int, int]:
    """画像サイズ取得"""
    size = img.size
    log_message(f"画像サイズ取得: {size}")
    return size

def get_max_image_size(images: List[Image.Image]) -> Tuple[int, int]:
    """画像リストから最大サイズを取得"""
    widths, heights = zip(*(get_image_size(img) for img in images))
    max_size = (max(widths), max(heights))
    log_message(f"最大画像サイズ: {max_size}")
    return max_size

def calc_grid_position(index: int, grid_cols: int, max_width: int, max_height: int) -> Tuple[int, int]:
    """グリッド座標計算"""
    x = (index % grid_cols) * max_width
    y = (index // grid_cols) * max_height
    log_message(f"グリッド座標計算: index={index}, x={x}, y={y}")
    return x, y

def determine_grid_size(image_count: int) -> Tuple[int, int]:
    """
    画像枚数に応じて最適なグリッドサイズを決定
    正方形に近い形になるよう計算
    """
    if image_count <= 0:
        return (1, 1)

    # 平方根を取って、正方形に近いグリッドサイズを計算
    sqrt_count = math.sqrt(image_count)

    # 列数は平方根を切り上げ
    cols = math.ceil(sqrt_count)

    # 行数は画像枚数を列数で割った値を切り上げ
    rows = math.ceil(image_count / cols)

    grid_size = (cols, rows)

    log_message(f"グリッドサイズ決定: {image_count}枚の画像に対して{grid_size}")
    return grid_size

def format_prompts(prompts: List[str]) -> str:
    """プロンプトリストを整形"""
    formatted_parts = []
    for i, p in enumerate(prompts):
        if i == len(prompts) - 1:  # 最後の画像
            formatted_parts.append(f"--- Image {i+1} ---\n{p}\n--- End of Images ---")
        else:
            formatted_parts.append(f"--- Image {i+1} ---\n{p}")
    formatted = "\n".join(formatted_parts)
    log_message(f"プロンプト整形: {formatted}")
    return formatted

def load_images(image_paths: List[Path]) -> List[Image.Image]:
    """画像ファイルを開いてリストで返す"""
    log_message(f"画像読み込み開始: {image_paths}")
    images = []
    for path in image_paths:
        try:
            img = Image.open(path)
            images.append(img)
            log_message(f"画像読み込み成功: {path}")
        except (OSError, IOError) as e:
            log_message(f"画像読み込み失敗: {path} ({e})")
            print(f"画像読み込み失敗: {path} ({e})", file=sys.stderr)
            raise RuntimeError(f"画像読み込み失敗: {path} ({e})") from e
    if not images:
        log_message("画像がありません(image_pathsが空です)")
        raise ValueError("画像がありません(image_pathsが空です)")
    return images

def create_resized_image(image: Image.Image, scale: float) -> Image.Image:
    """画像を指定倍率でリサイズ"""
    original_size = image.size
    new_size = (int(original_size[0] * scale), int(original_size[1] * scale))
    resized = image.resize(new_size, Image.Resampling.LANCZOS)
    log_message(f"画像リサイズ: {original_size} -> {new_size} (倍率: {scale})")
    return resized

def create_resized_to_original_size(image_paths: List[Path], combined_image: Image.Image) -> Image.Image:
    """連結画像を短い辺が元画像の短い辺になるようにリサイズ"""
    # 最初の画像のサイズを基準とする
    with Image.open(image_paths[0]) as first_img:
        original_width, original_height = first_img.size

    combined_width, combined_height = combined_image.size

    # 元画像の短い辺を取得
    original_short_side = min(original_width, original_height)

    # 連結画像の短い辺を取得
    combined_short_side = min(combined_width, combined_height)

    # 短い辺を元画像の短い辺に合わせるスケールを計算
    scale = original_short_side / combined_short_side

    target_width = int(combined_width * scale)
    target_height = int(combined_height * scale)

    resized = combined_image.resize((target_width, target_height), Image.Resampling.LANCZOS)
    log_message(f"連結画像を短い辺基準でリサイズ: {combined_image.size} -> {target_width}x{target_height} (スケール: {scale:.4f}, 元画像短い辺: {original_short_side})")
    return resized

def combine_images(image_paths: List[Path], grid_size: Tuple[int, int]) -> Image.Image:
    """
    画像をグリッド状に連結
    """
    log_message(f"combine_images: 開始 {image_paths}, grid_size={grid_size}")
    images = load_images(image_paths)
    max_width, max_height = get_max_image_size(images)
    grid_cols, grid_rows = grid_size
    if len(set(img.size[0] for img in images)) > 1 or len(set(img.size[1] for img in images)) > 1:
        log_message("警告: 画像サイズが一致していません。最大サイズで連結します。")
        print("警告: 画像サイズが一致していません。最大サイズで連結します。", file=sys.stderr)
    combined_image = Image.new('RGB', (max_width * grid_cols, max_height * grid_rows))
    for index, img in enumerate(images):
        x, y = calc_grid_position(index, grid_cols, max_width, max_height)
        combined_image.paste(img, (x, y))
        log_message(f"画像貼り付け: index={index}, x={x}, y={y}")
    for img in images:
        img.close()
    log_message("combine_images: 完了")
    return combined_image

def collect_prompts(image_paths: List[Path]) -> List[str]:
    """画像ごとのプロンプトを抽出"""
    log_message(f"collect_prompts: 開始 {image_paths}")
    prompts = []
    for path in image_paths:
        prompt = extract_prompt_metadata(path)
        prompts.append(prompt or "[No parameters]")
        log_message(f"プロンプト抽出: {path} -> {prompt}")
    log_message(f"collect_prompts: 完了 {prompts}")
    return prompts

if __name__ == "__main__":
    main()
Edit

Pub: 06 Aug 2025 12:19 UTC

Edit: 06 Aug 2025 12:31 UTC

Views: 391