大量のJPEGファイルからサムネイルと閲覧用htmlファイルを自動生成するPythonスクリプト(2019.3.23, 2026.1.4追記)

Summary

大量のJPEGファイルを効率よく共有するために、サムネイル画像と閲覧用htmlを自動生成するPythonスクリプトを作成。 JPEGファイルの入ったフォルダを指定すると、ファイルサイズが大きいJPEGファイルに対して、軽量のサムネイル画像を自動生成し、且つ、それらを一覧表示して元画像へのリンクを貼ったhtmlテキストも生成する。 運動会やパーティなどのイベントで、調子に乗って大量に写真を撮影すると、後で参加者に共有するのが面倒なので、共有用のhtmlを自動生成できるようにした。

Flick対応: iPadなどタッチスクリーンに対応した機器ではflickに対応した(GitHub Copilotに書いてもらった)。Swiperの既知の不具合(https://github.com/nolimits4web/swiper/issues/7584)により、最初と最後の画像をflickすると画像の表示中心がズレて一部しか画面に表示されなくなる。(2026.1.4追記)

何ができる?

例えば、下記のように”./JPG/“以下にJPEGファイルがたくさんあるとき、対応するサムネイル画像を”./JPG/s/“以下に生成し、且つ、それらの画像ファイルを参照するための./index.htmlおよび./flick_view.htmlを出力する。”[]“で囲んだファイルが、本スクリプトが生成するファイル。 生成された./index.htmlと./JPG/以下のファイルをwebサーバに配置すれば、サムネイル画像を参照してファイルをダウンロードできるようになる。

.
├── JPG
│   ├── IMG_2975.JPG
│            :
│   ├── IMG_3000.JPG
│   └── s
│       ├── [IMG_2975s.JPG]
│                 :
│       └── [IMG_3000s.JPG]
├── [index.html]
└── pictlist.py

pictlist_flick.pyの使い方(2026.1.4追記)

pictlist_flick.pyをindex.htmlが生成されるべき場所に置いて、JPEGファイルが入ったフォルダのパスを指定する。このとき、サムネイル画像はJPEGファイルのフォルダの中にs/という名前のフォルダを作っておくこと。 たとえば、配布したいJPEGファイルが”/www/party/JPG”にあり、サムネイルフォルダが”/www/party/JPG/s/“であるようなケースを想定している。(元画像のファイル名がimg001.jpgならサムネイル画像ファイル名はimg001s.jpg) この場合に、”/www/party/“にpictlist_flick.pyを置いて以下のように実行を設定し、”/www/party/“にカレントディレクトリを移動して実行するする。(JPEGファイルを参照するindex.htmlが生成されるべきフォルダにcdした状態でpictlist_flick.pyを実行する。) この場所にindex.htmlとflick_view.htmlが作成される。 一度サムネイルが作成されると、画像の処理は行わずhtmlのみを出力する。-Hオプションの値を変更して上書きしたい場合は-Fオプションを指定する。

準備

$ cd ~/www/party/
$ cp [どこか]/pitclist_flick.py ./
$ chmod a+x ./pictlist_flick.py

実行方法

$ ./pictlist_flick.py ./JPG/ -H 200 -t "送別会"

第一引数にJPEGファイルのフォルダを指定するが、index.htmlファイルは、現在のディレクトリからの相対位置で与えられたURLを生成することに注意。もしも./JPG/フォルダの中にindex.htmlを置きたいなら、 カレントディレクトリを./JPG/内に移動して、以下のように実行する。 このとき、サムネイルフォルダ”./s/“が存在している必要がある。 画像の縦の長さを’-H 200’のようにpixel指定する。’-t”タイトル”’でタイトルを指定。

$ ./pictlist.py ./ -H 200 -t "送別会"

ソースコード(pictlist_flick.py)

リファクタリングにはGitHub Copilot (ChatGPT-4o)を用いた。 工夫した点は以下の通り

Swiperの既知の不具合により、最初と最後の画像をflickすると画像の表示中心がズレて一部しか画面に表示されなくなる。(Swiper version 11で発生するらしい)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# =======================================================
#  JPEGファイルを表示するhtmlを自動生成するスクリプト
#  JPEGファイルのサイズが大きい時はthumbnail画像を自動生成
#
#  pictlist_flick.py
#  coded by Noboru Harada (noboru@ieee.org)
#
#  Changes:
#  2019/03/23: First version
#  2025/01/04: Updated to use Swiper for slideshow (with GitHub Copilot)
#
#  使い方
#  > pictlist_flick.py ./JPG/ -H 200 -t "運動会" | tee index.html
#  -Hオプションで画像の高さを指定
#  -tオプションでhtmlに表示されるタイトル文字列を指定
#  thumbnailファイルは、元のファイル名にsをつけたもの
#  たとえば"./JPG/img01.jpg"なら"./JPG/s/img01s.jpg"
#  指定したフォルダの中に s/があることを想定
#  -sで指定可能 -s ./JPG/s/
#  元のJPEGファイルのサイズが一定以下の場合はサムネイル画像は作らずコピー
#  サムネイル画像が既に存在する場合には作成しない
#  ただし、-Fオプションが指定されて入れば上書き生成
# =======================================================


# generate html text for picture and thumbnail
import os
import shutil
import subprocess as sp
import re
from PIL import Image
import argparse
import logging
from typing import List

# Constants
DEFAULT_TITLE = "写真"
DEFAULT_JPG_PATH = "./JPG/"
DEFAULT_THUMBNAIL_PATH = "./JPG/s/"
DEFAULT_HEIGHT = 200
DEFAULT_MAX_FILE_SIZE = 200000  # 200KB

# Configure logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

def generate_thumbnail(image_path: str, thumbnail_path: str, size: tuple, overwrite: bool = False) -> None:
    """Generate a thumbnail for the given image."""
    try:
        image = Image.open(image_path)
        if not os.path.exists(thumbnail_path) or overwrite:
            image.thumbnail(size)
            image.save(thumbnail_path, "JPEG")
            logging.info(f"Thumbnail created: {thumbnail_path}")
        else:
            logging.info(f"Thumbnail already exists: {thumbnail_path}")
    except IOError:
        logging.error(f"Failed to process image: {image_path}")

def generate_html(file_path: str, content: List[str]) -> None:
    """Write HTML content to a file."""
    with open(file_path, "w") as f:
        f.write("\n".join(content))

def generate_page(jpg_path: str, thumbnail_path: str, height: int, max_file_size: int, title: str, overwrite: bool = False) -> None:
    """Generate both thumbnail and Swiper pages."""
    file_names = sorted(f.name for f in os.scandir(jpg_path) if f.is_file())

    # Generate index.html (thumbnail page)
    thumbnail_content = [
        "<!DOCTYPE html>",
        "<html>",
        f"<head><title>{title} Thumbnail Page</title></head>",
        "<meta charset=\"utf-8\" />",
        "<meta name=\"generator\" content=\"pictlist.py\" />",
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=yes\" />",
        "<body>",
        f"<h1>{title}</h1>",
        "サムネイル画像が表示されています。クリックすると元サイズの画像が表示されます。<br>",
        "このhtmlはPythonスクリプト(<a href=\"https://www.nharada.com/programming/pictlist_flick.html\">pictlist_flick.py</a>)を使用して作成しました。<br>",
        "<p>"
    ]

    for file_name in file_names:
        if file_name.lower().endswith(('.jpg', '.jpeg')):
            original_path = os.path.join(jpg_path, file_name)
            thumbnail_file = file_name.replace('.jpg', 's.jpg').replace('.JPG', 's.JPG')
            thumbnail_full_path = os.path.join(thumbnail_path, thumbnail_file)

            # Generate thumbnail
            generate_thumbnail(original_path, thumbnail_full_path, (height, height), overwrite)

            # Add thumbnail link
            thumbnail_content.append(
                f'<a href="flick_view.html#{file_name}"><img src="{thumbnail_full_path}" height="{height}" alt="{file_name}"></a>'
            )

    thumbnail_content.append("</body></html>")
    generate_html("index.html", thumbnail_content)

    # Generate flick_view.html (Swiper page)
    swiper_content = [
        "<!DOCTYPE html>",
        "<html>",
        "<head>",
        "<link rel=\"stylesheet\" href=\"https://unpkg.com/swiper/swiper-bundle.min.css\" />",
        "<script src=\"https://unpkg.com/swiper/swiper-bundle.min.js\"></script>",
        "<style>",
        ".swiper { width: 100%; height: 100%; }",
        ".swiper-slide { text-align: center; font-size: 18px; background: #fff; }",
        "</style>",
        "</head>",
        "<meta charset=\"utf-8\" />",
        "<body>",
        "<a href=\"index.html\">Back to the front page with thumbnails</a>",
        "<div class=\"swiper\">",
        "<div class=\"swiper-wrapper\">",
    ]

    for file_name in file_names:
        if file_name.lower().endswith(('.jpg', '.jpeg')):
            swiper_content.append(
                f'<div class="swiper-slide" id="{file_name}"><img src="{jpg_path}{file_name}" style="width:100%;" alt="{file_name}"></div>'
            )

    swiper_content.extend([
        "</div>",
        "<div class=\"swiper-pagination\"></div>",
        "<div class=\"swiper-button-prev\"></div>",
        "<div class=\"swiper-button-next\"></div>",
        "</div>",
        "<script>",
        "const swiper = new Swiper('.swiper', { loop: true, pagination: { el: '.swiper-pagination', clickable: true }, navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' } });",
        "</script>",
        "</body>",
        "</html>",
    ])

    generate_html("flick_view.html", swiper_content)

def getargs():
    """Parse command-line arguments."""
    parser = argparse.ArgumentParser(
        description="Generate HTML for images and thumbnails."
    )
    parser.add_argument("jpg_path", type=str, help="Path to JPEG files (e.g., ./JPG/)")
    parser.add_argument(
        "-s", "--s_path", type=str, help="Path to thumbnail directory (default: jpg_path/s/)"
    )
    parser.add_argument(
        "-t", "--title", type=str, default=DEFAULT_TITLE, help="Title for the HTML page"
    )
    parser.add_argument(
        "-H", "--Height", type=int, default=DEFAULT_HEIGHT, help="Thumbnail height in pixels"
    )
    parser.add_argument(
        "-m", "--maxsize", type=str, help="Max file size for thumbnails (default: 200KB)"
    )
    parser.add_argument(
        "-F", "--Force", action="store_true", help="Force overwrite of thumbnails"
    )
    return parser.parse_args()

def main():
    args = getargs()
    jpg_path = args.jpg_path.rstrip('/¥') + "/"
    thumbnail_path = args.s_path.rstrip('/¥') + "/" if args.s_path else jpg_path + "s/"
    height = args.Height or DEFAULT_HEIGHT
    max_file_size = args.maxsize or DEFAULT_MAX_FILE_SIZE
    title = args.title or DEFAULT_TITLE
    overwrite = args.Force

    if not os.path.exists(jpg_path):
        logging.error(f"Input folder does not exist: {jpg_path}")
        exit(-1)
    if not os.path.exists(thumbnail_path):
        logging.error(f"Thumbnail folder does not exist: {thumbnail_path}")
        exit(-1)

    generate_page(jpg_path, thumbnail_path, height, max_file_size, title, overwrite)

if __name__ == "__main__":
    main()

古いver(pictlist.py)の使い方

pictlist.pyをindex.htmlが生成されるべき場所に置いて、JPEGファイルが入ったフォルダのパスを指定する。このとき、サムネイル画像はJPEGファイルのフォルダの中にs/という名前のフォルダを作っておくこと。 たとえば、配布したいJPEGファイルが”/www/party/JPG”にあり、サムネイルフォルダが”/www/party/JPG/s/“であるようなケースを想定している。(元画像のファイル名がimg001.jpgならサムネイル画像ファイル名はimg001s.jpg) この場合に、”/www/party/“にpictlist.pyを置いて以下のように実行を設定し、”/www/party/“にカレントディレクトリを移動して実行するする。(JPEGファイルを参照するindex.htmlが生成されるべきフォルダにcdした状態でpictlist.pyを実行する。) htmlは標準出力に出力されるので、リダイレクトでindex.htmlに保存する。一度サムネイルが作成されると、画像の処理は行わずhtmlのみを出力する。-Hオプションの値を変更して上書きしたい場合は-Fオプションを指定する。

準備

$ cd ~/www/party/
$ cp [どこか]/pitclist.py ./
$ chmod a+x ./pictlist.py

実行方法

$ ./pictlist.py ./JPG/ -H 200 -t "送別会" | tee ./index.html

第一引数にJPEGファイルのフォルダを指定するが、index.htmlファイルは、現在のディレクトリからの相対位置で与えられたURLを生成することに注意。もしも./JPG/フォルダの中にindex.htmlを置きたいなら、 カレントディレクトリを./JPG/内に移動して、以下のように実行する。 このとき、サムネイルフォルダ”./s/“が存在している必要がある。 画像の縦の長さを’-H 200’のようにpixel指定する。’-t”タイトル”’でタイトルを指定。

$ ./pictlist.py ./ -H 200 -t "送別会" | tee ./index.html

ソースコード(pictlist.py)

thumbnail画像の自動生成にはPillowを使用した。 工夫した点は以下の通り


#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# =======================================================
#  JPEGファイルを表示するhtmlを自動生成するスクリプト
#  JPEGファイルのサイズが大きい時はthumbnail画像を自動生成
#
#  pictlist.py
#  coded by Noboru Harada (noboru@ieee.org)
#
#  Changes:
#  2019/03/23: First version
#
#  使い方
#  > pictlist.py ./JPG/ -H 200 -t "運動会" | tee index.html
#  -Hオプションで画像の高さを指定
#  -tオプションでhtmlに表示されるタイトル文字列を指定
#  thumbnailファイルは、元のファイル名にsをつけたもの
#  たとえば"./JPG/img01.jpg"なら"./JPG/s/img01s.jpg"
#  指定したフォルダの中に s/があることを想定
#  -sで指定可能 -s ./JPG/s/
#  元のJPEGファイルのサイズが一定以下の場合はサムネイル画像は作らずコピー
#  サムネイル画像が既に存在する場合には作成しない
#  ただし、-Fオプションが指定されて入れば上書き生成
# =======================================================


# generate html text for picture and thumbnail
import os
import shutil
import subprocess as sp
import re
from PIL import Image
import argparse

# 初期値の設定
title       = "写真"
jpg_path    = "./JPG/"
s_path      = "./JPG/s/"
s_height    = 200
max_fsize   = 200000    # 200KB
Force        = False

# コマンドライン引数の読み込み設定
def getargs():
    parser = argparse.ArgumentParser(
        description="generate html text for picture and thumbnail")
    parser.add_argument("jpg_path", type=str,
                        help="JPEG file path (e.g. ./JPG/)")
    parser.add_argument("-s", "--s_path", type=str,
                        help="specify thumbnail dir (if not specified, jpg_path/s/")
    parser.add_argument("-t", "--title", type=str, default="写真",
                        help="specify title (default \"写真\")")
    parser.add_argument("-H", "--Height", type=int, default=s_height,
                        help="height of the thumbnail picture in number of pixels (defult = 200)")
    parser.add_argument("-m", "--maxsize", type=str,
                        help="file size threshold for generating thumbnail (default 200KB)")
    parser.add_argument("-F", "--Force", action='store_true',
                        help="Force over-wright for generating thumbnail files")
    args = parser.parse_args()
    return args


def put_body(jpg_path, s_path, s_height, max_fsize):
    # use scandir
    ls_file_name = [f.name for f in os.scandir(jpg_path)]
    # sortしないと順番がバラバラになる
    ls_file_name.sort()

    #print(ls_file_name)

    for fname in ls_file_name:
        #print("try: " + jpg_path + fname)
        if os.path.isfile(jpg_path+fname):
            try:
                image = Image.open(jpg_path + fname)
                fsize = os.path.getsize(jpg_path + fname)

                # 縦横比率を保存して、高さが-Hになるようなサムネイル画像のサイズを計算
                w, h = image.size
                s_width = w * s_height / h
                s_size = s_width, s_height

                if '.JPG' in fname:
                    sfile = fname.replace('.JPG', 's.JPG')
                elif '.jpg' in fname:
                    sfile = fname.replace('.jpg', 's.jpg')

                # サムネイル画像ファイルが既に存在する場合は何もしない
                if not os.path.exists(s_path+sfile):
                    if fsize > max_fsize:
                        image.thumbnail(s_size)
                        image.save(s_path+sfile, "JPEG")
                    else:
                        shutil.copyfile(jpg_path+fname, s_path+sfile)
                # Forceオプションが指定されている場合はファイルを上書き
                elif Force:
                    if fsize > max_fsize:
                        image.thumbnail(s_size)
                        image.save(s_path+sfile, "JPEG")
                    else:
                        shutil.copyfile(jpg_path+fname, s_path+sfile)

                # サムネイル画像を表示して元画像を参照するリンクを生成
                print(" <a href=\"" + jpg_path + fname + "\"><img src=\""
                      + s_path + sfile + "\" height=\""+str(s_height)+"\" alt=\""
                      + jpg_path + fname + "\" /></a>")

            except IOError:
                #print("IOError: "+fname+" is not an Image file")
                pass

def put_header(title):
    print(
        "<!DOCTYPE html>\n"
        "<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"\" xml:lang=\"\">\n"
        "<head>\n"
        "<meta charset=\"utf-8\" />\n"
        "<meta name=\"generator\" content=\"pictlist.py\" />\n"
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=yes\" />\n"
        "<title>index</title>\n"
        "<style type=\"text/css\">\n"
        "code{white-space: pre-wrap;}\n"
        "span.smallcaps{font-variant: small-caps;}\n"
        "span.underline{text-decoration: underline;}\n"
        "div.column{display: inline-block; vertical-align: top; width: 50%;}\n"
        "</style>\n"
        "<!--[if lt IE 9]>\n"
        "<script src=\"//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js\"></script>\n"
        "<![endif]-->\n"
        "</head>\n"
        "<body>\n"
        "<h1 id=\""+title+"\">"+title+"</h1>\n"
        "サムネイル画像が表示されています。クリックすると元サイズの画像が表示されます。<br>\n"
        "このhtmlはPythonスクリプト(<a href=\"http://www.nharada.jpn.org/programming/pictlist.html\">pictlist.py</a>)を使用して作成しました。\n"
        "<p>"
    )

def put_footer():
    print(
        "</p>\n"
        "</body>\n"
        "</html>"
    )


if __name__ == '__main__':

    # コマンドライン引数の読み込み
    args = getargs()

    jpg_path = args.jpg_path.rstrip('/¥')+"/"
    if args.s_path:
        s_path = args.s_path.rstrip('/¥')+"/"
    else:
        s_path = jpg_path+"s/"

    # ファイルの入出力フォルダが見つからない場合はエラー終了
    if not os.path.exists(jpg_path):
        print(jpg_path+" does not exist")
        exit(-1)
    if not os.path.exists(s_path):
        print(s_path+" does not exist")
        exit(-1)

    if args.maxsize:
        if "MB" in args.maxsize.upper():
            max_fsize = 1000 * 1000 * int(args.maxsize.upper().replace("MB", ""))
        elif "KB" in args.maxsize.upper():
            max_fsize = 1000 * int(args.maxsize.upper().replace("KB", ""))
        else:
            max_fsize = int(args.maxsize)

    if args.Height:
        s_height = args.Height

    if args.title:
        title = args.title
    Force = args.Force

    # 実際のhtml書き出し処理
    put_header(title)
    #generate thumbnail only when JPEG file size exceeds max otherwise copy
    put_body(jpg_path, s_path, s_height, max_fsize)
    put_footer()

参考

Back to Index