pythonで大量の写真をサクサク閲覧できるフォトギャラリーを作ってみた

ローカルにため込んだ写真などを一覧表示するため、ブラウザ上で動くフォトギャラリーを作りました。

使うのはPythonです。特別なことはしていないので、importしてあるものを普通にpip installしてもらえれば動くと思います。


# 今回の取り組みの経緯
[pythonを使って溜まりに溜まりまくる写真を整理整頓してみる](https://qiita.com/azuwai2/items/4f796081fe0d90122dbe)以降の取り組みによって、大量の写真を一元保管することに成功しました。が、せっかく溜め込んだ写真なのですが、フォルダ階層を刻んで保管しているせいで、見るのには大層不便であるということに気づきました。

---
とりあえず、よくあるビューワアプリなどでは、以下の不満が解消できませんでした。
- 日付やモデルでフォルダ階層が分かれているせいでフォルダの移動が面倒くさい。
- うっかりフォルダをドラッグして写真整理フォルダの階層を崩してしまいそうになる。
- サムネのサイズを指定できる最大にしてもまだ小さい。
- カタログ/サムネの作成に時間がかかる。
- バックアップ先をうっかり閲覧すると、別個にカタログやらサムネデータベースやらが構築されてしまう。

ほぼ自業自得です。

---


クラウドサービスにずっぽりと突っ込んで、日付やら人物で抽出して閲覧するような風潮には今一つ付いていけないタチですし、そもそも写真を選りすぐるということをしていないので、とりあえず残っているピンぼけ写真や白トビ黒潰れ写真などまで含めてアップロードしたりダウンロードするのにかかる時間・帯域のことを考えると気も進みません。

なんとかローカルにため込んだままサクサクと閲覧したいのです。

**・・・そうだ！htmlで書いて、ブラウザで見ればいいじゃない！（ポンッ）**

# 要望
- 保管するときのフォルダ構成を変えることなく、見るときにはサブフォルダ配下のファイルをぜーんぶ見たい。
- フォルダ構成はモデル別に分けてあるのですが、閲覧時はモデルは気にせず撮影時刻順に並べて見たい。
- スマホのようにサムネがダバーっと途切れなく並んでいて、サッサと閲覧できるような感じ。

# トライ
## ChatGPTに丸投げ
まずは脳死状態で、ChatGPT先生に頼んでみます。
実際には何度かのやり取りでしたが、総括するとこんな感じのお願いをしました。

> あるフォルダ配下のすべての画像ファイルを、exifの撮影日時の時系列に表示するhtml/cssを生成するpthonスクリプトを作ってください。
> - exifに撮影日時がない画像データは表示しなくてもよいです。
> - exifの情報に従って、縦横表示は適切に行ってください。
> - 日付でジャンプできるリストが左側にフロート表示されるようにしてください。
> - サムネイル画像などは生成せずに、直接元画像を表示してください。
> - Lazy loadingを指定して、表示の遅延を抑えるようにしてください。
> - 1行に3列の画像を表示するようにしてください。
> - サブフォルダ配下もすべて表示対象にしてください。
> - 生成中に、プログレスバーを表示して、どの程度処理が進んでいるのかわかるようにしてください。

## ChatGPT先生からのご回答
こんな回答をいただきました。
<details> <summary>回答内容（最終的なコードのみ抜粋）</summary>

```.py
import os
from PIL import Image
from datetime import datetime
from jinja2 import Template
from tqdm import tqdm  # tqdmを追加

def get_images_in_folder(folder_path):
    image_list = []
    for root, dirs, files in os.walk(folder_path):
        for file in files:
            if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')):
                image_path = os.path.join(root, file)
                image_list.append(image_path)
    return image_list

#  フォルダのパス
folder_path = '/path/to/your/images/folder'

#  HTMLテンプレート
template_str = """
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Image Gallery</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
        }

        #gallery {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            margin: 20px;
        }

        .thumbnail {
            width: 30%;
            margin: 10px;
        }

        .date-list {
            position: fixed;
            left: 0;
            top: 0;
            padding: 20px;
            background-color: #f0f0f0;
        }

        .date-list a {
            display: block;
            margin-bottom: 10px;
            text-decoration: none;
            color: #333;
        }
    </style>
</head>
<body>
    <div id="gallery">
        {% for date in unique_dates %}
            <h2 id="{{ date }}">{{ date }}</h2>
            {% for image in grouped_images[date] %}
                <div class="thumbnail">
                    <img src="{{ image.path }}" loading="lazy" alt="{{ image.name }}">
                </div>
            {% endfor %}
        {% endfor %}
    </div>

    <div class="date-list">
        {% for date in unique_dates %}
            <a href="#{{ date }}">{{ date }}</a>
        {% endfor %}
    </div>
</body>
</html>
"""

#  フォルダ内の画像を取得
image_list = get_images_in_folder(folder_path)

#  撮影日時を取得してソート
images_with_date = []
for image_path in tqdm(image_list, desc="Processing images", unit="image"):  # tqdmを使用してプログレスバーを表示
    try:
        with Image.open(image_path) as img:
            exif_data = img._getexif()
            if exif_data and 36867 in exif_data:  # 36867はExifのDateTimeOriginalのタグ
                date_str = exif_data[36867]
                date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
                images_with_date.append({'path': image_path, 'name': os.path.basename(image_path), 'date': date_obj})
    except (AttributeError, ValueError, OSError):
        pass

#  撮影日時でソート
images_with_date.sort(key=lambda x: x['date'])

#  重複なしの日付リストを作成
unique_dates = list(set(date.strftime('%Y-%m-%d') for date in [image['date'] for image in images_with_date]))

#  日付ごとに画像をグループ化
grouped_images = {date: [] for date in unique_dates}
for image in images_with_date:
    grouped_images[image['date'].strftime('%Y-%m-%d')].append(image)

#  HTMLテンプレートに渡すデータを作成
template_data = {
    'unique_dates': unique_dates,
    'grouped_images': grouped_images,
}

#  テンプレートをレンダリング
template = Template(template_str)
html_output = template.render(**template_data)

#  HTMLファイルに保存
output_path = '/path/to/your/output/gallery.html'
with open(output_path, 'w', encoding='utf-8') as html_file:
    html_file.write(html_output)

print(f"Gallery HTML file generated at: {output_path}")
```
</details>

すでに大枠はできてしまった感じです。
ヤバい。jinja2 超便利ですね。

folder_path やら output_path がベタ打ちなので、その辺をコチョコチョしたりして実際に動かすまでの細かいことは省きますが、**まぁ、重くて使い物になりませんでした。**
どうやらLazy Loadingに夢を抱きすぎていたようです。

この要望がとにかくダメなポイントだったと思います。
>- サムネイル画像などは生成せずに、直接元画像を表示してください。

まずは素直にサムネイルを別途作成することにしましょう。

# サムネ作成
サムネファイルを作成する場所について右往左往しましたが、経緯は省いて、結論だけ掲載します。

こうなりました。
<details> <summary>thumbnail_generator.py</summary>

```thumbnail_generator.py
import os,sys
from PIL import Image, ExifTags
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor
import cv2      #動画のサムネ作成
import tempfile #cv2が日本語パスに対応できないので
import shutil  #move

PREFIX_THUMBNAIL = 'tmb_'
THUMBNAIL_DIR = 'thumbnail'

def resize_and_save_image(input_path, output_path, max_size=600, quality=75):
    try:
        with Image.open(input_path) as img:
            # 画像の向きを調整（Exif情報を保持）
            for orientation in ExifTags.TAGS.keys():
                if ExifTags.TAGS[orientation] == 'Orientation':
                    break

            try:
                exif = dict(img._getexif().items())
                if exif[orientation] == 3:
                    img = img.rotate(180, expand=True)
                elif exif[orientation] == 6:
                    img = img.rotate(270, expand=True)
                elif exif[orientation] == 8:
                    img = img.rotate(90, expand=True)
            except (AttributeError, KeyError, IndexError):
                # 画像にExif情報がない場合や、Orientationが存在しない場合は無視
                pass

            # 画像の縦横サイズを取得
            width, height = img.size

            # 長辺が指定 pixelになるように縮小 →横に並べるので、横幅が指定pixelにする。
            #if width > height:
            #    new_width = max_size
            #    new_height = int(height * (max_size / width))
            #else:
            #    new_width = int(width * (max_size / height))
            #    new_height = max_size
            new_width = max_size
            new_height = int(height * (max_size / width))

            img.thumbnail((new_width, new_height), Image.LANCZOS)

            try:
                if not os.path.exists(os.path.dirname(output_path)): #保存先フォルダが存在しない場合は、
                    os.makedirs(os.path.dirname(output_path))       #フォルダを作成
            except(OSError):
                pass

            # 画像を保存
            img.save(output_path, format="WEBP", quality=quality)

        return True, f"Image {input_path} resized and saved to {output_path}"
    except Exception as e:
        return False, f"Error processing {input_path}/{output_path}: {e}"

def resize_and_save_move(input_path, output_path, max_size=600, quality=75):
    try:
        cap = cv2.VideoCapture(input_path)
        if not cap.isOpened(): 
            return False, f"Error processing {input_path}/{output_path}: VideoCapture open"

        _, img = cap.read()
        height = img.shape[0]
        width = img.shape[1]

        # リサイズ
        newWidth = max_size
        newHeight = int(height * newWidth / width)
        img = cv2.resize(img, (newWidth, newHeight))

        outputDir= os.path.dirname(output_path)

        try:
            if not os.path.exists(outputDir): #保存先フォルダが存在しない場合は、
                os.makedirs(outputDir)       #フォルダを作成
        except(OSError):
            pass

        with tempfile.TemporaryDirectory() as tmpDir:   #一時フォルダに、
            tmpFile=os.path.join(tmpDir,"tmp.webp")
            cv2.imwrite(tmpFile, img, [int(cv2.IMWRITE_WEBP_QUALITY), quality]) # 画像を保存
            shutil.move(tmpFile, output_path)           #本来の出力先へ移動

        return True, f"Image {input_path} resized and saved to {output_path}"
    except Exception as e:
        return False, f"Error processing {input_path}/{output_path}: {e}"

def process_images_in_directory(directory_path):
    tmb_root = os.path.join(directory_path,THUMBNAIL_DIR)

    with ThreadPoolExecutor(max_workers=5) as executor:
        futures = []
        for root, dirs, files in os.walk(directory_path):
            for file in files:
                if root.startswith(tmb_root):
                    #サムネフォルダ内（「tmb_～.webp」しか存在しないハズ。あとはギャラリー用のhtmlとか？）
                    if file.startswith(PREFIX_THUMBNAIL) and file.endswith(".webp"):
                        dir_path_img = root.replace(tmb_root,directory_path)        #元画像の格納パス、古パス
                        file_path_img = os.path.join(dir_path_img,file.removeprefix(PREFIX_THUMBNAIL).removesuffix(".webp"))
                        if not os.path.exists(file_path_img):                       #元画像が存在しない場合は、
                            os.remove(os.path.join(root,file))                      #サムネイルを削除
                else:
                    #実ファイル側
                    # サムネ作成対象（画像ファイル＆動画ファイル）
                    if file.lower().endswith((".jpg", ".jpeg", ".png", ".mp4", ".mov", ".avi", ".mts")):
                        dir_path_tmb  = root.replace(directory_path,tmb_root)       #サムネの格納パス、フルパス
                        file_path_tmb = os.path.join(dir_path_tmb, PREFIX_THUMBNAIL+file+".webp")
                        if not os.path.exists(file_path_tmb):                       #存在しない場合のみ作成対象。
                            file_path_img = os.path.join(root, file)
                            if file.lower().endswith((".jpg", ".jpeg", ".png")):
                                #画像ファイル
                                futures.append(executor.submit(resize_and_save_image, file_path_img, file_path_tmb))
                            else:
                                #動画ファイル
                                futures.append(executor.submit(resize_and_save_move, file_path_img, file_path_tmb))


        successful_count = 0
        error_count = 0

        for future in tqdm(futures, desc="Processing images", total=len(futures)):
            success, message = future.result()
            if success:
                successful_count += 1
            else:
                error_count += 1
                tqdm.write(message)

        tqdm.write(f"Processing complete. {successful_count} images processed successfully, {error_count} errors.")



# 処理対象のディレクトリパスを取得
if len(sys.argv) != 2:
    print('対象フォルダを指定してください。')
    sys.exit()

target_directory = sys.argv[1]
if not os.path.isdir(target_directory):
    print(f'"{target_directory}"はフォルダではありません。')
    print('対象フォルダを指定してください。')
    sys.exit()

print(target_directory)

# 処理を実行
process_images_in_directory(target_directory)
```
</details>

## サムネのフォルダ構成
最初は、元ファイルと同じフォルダにサムネイルを作ろうとしたのですが、エクスプローラや他のビューワアプリで見たときに邪魔になるので、別階層に保存することにしました。

画像が詰まった親フォルダを指定して実行すると、そのフォルダに「thumbnail」というフォルダを作成して、同じフォルダ構成でサムネを作成して格納します。

こんな感じになります。
```
D:\デジカメ(原本)\データ\日付別
├\2009
：
├\2024
│　├\2024mmdd
│　：　└\XXX(Model名)
│　　　└001.jpg
│
└\thumbnail　　　←これを作成する。
　├\2009
　：
　└\2024
　　├\2024mmdd
　　：　└\XXX(Model名)
　　　　　└tmb_001.jpg.webp
```

## サムネ作成の動かし方
対象フォルダを指定して動かすだけです。
まだWindowsユーザーですので、こんな感じのbatファイルを作りました。

```.bat
@echo off
set targetFolder=D:\デジカメ(原本)\データ\日付別
python thumbnail_generator.py %targetFolder%
pause
```


## メイン処理`process_images_in_directory`
指定されたフォルダ配下すべてを繰り返して、サムネ作成を行います。
元ファイルが無くなっていた場合は、サムネファイルにも消込をかけるようにしています。
サムネのファイル名はすべて`tmb_<元ファイル名>.webp`とします。


## 画像のサムネイル`resize_and_save_image`
- PILのImageで画像を開いて、
- Exif Orientation に従って 画像.rotate() で回転させて、
- 縦横サイズ（※）を決めてから、画像.thumbnail() で 縮小して、
- 画像.save() で 保存。

です。以上！

※横600ドットなのは、3枚横並びを個人的な要件にしているので、1920を3で割った値に近い適当な数値です。
WQHD(2560x1440)みたいなので見ると、多少アラが見えますが、まぁサムネですし。

## 動画のサムネイル`resize_and_save_move`
- cv2.VideoCapture で動画を開いて、
- 動画.read()で、１コマ目の情報を取得して、
- 縦横サイズ（※同上）を決めてから、cv2.resize()で縮小して、
- cv2.imwrite()で、画像を保存。

以上！

cv2.imwrite()は、日本語パス上だとエラーを吐くことがあるので、tempfile.TemporaryDirectory() を使用しています。



# ギャラリー作成
オリジナルファイルを並べるだけという暴挙を阻止しましたので、サムネを表示することになりましたが、さすがに段違いに軽いです。400GBチョイのJPG画像に対して、サムネの総容量はたったの（？）2GBです。

上記のサムネ作成で作成した、フォルダ構成を保った「thumbnail」フォルダが必要です。

で、並んでいるサムネをクリックしたらオリジナルファイルが見られるようにする必要があります。まぁ、その辺はググりながら JavaScript でコチョコチョ（綺麗にしていないので詳細割愛）しました。

こうなりました。
<details> <summary>gallery_generator.py</summary>

```gallery_generator.py
import os,sys
from PIL import Image
import piexif
from datetime import datetime
from jinja2 import Template
from tqdm import tqdm
from pprint import pprint

def get_images_in_folder(folder_path):
    image_list = []
    for root, dirs, files in os.walk(folder_path):
        for file in files:
            if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.mp4', '.mov', '.avi' , '.mts')) and not file.startswith('tmb_'):
                image_path = os.path.join(root, file)
                image_list.append(image_path)
    return image_list


# HTMLテンプレート
template_str = r"""
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Image Gallery</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
        }

        #fullscreen-viewer {
            margin: 0 0 0 0;
            position: absolute;
            z-index:1;
            height:100%;
            width:100%;
            display:none;
            overflow: hidden;
            background-color:black;
        }
        #vimg {
            height:100%;
            width:100%;
            object-fit:contain;
            transform-origin: center center;
        }

        #gallery {
            margin: 0 80pt 0 0; /*右端だけ少し開ける（日付リスト用）*/
            position: fixed;
            overflow-y: auto;
            max-height: 100vh;
        }

        .date {
            display: flex;
            flex-wrap: wrap;
            justify-content: left; /* 2つ以下の場合左寄せになるように。 */
        }

        .date-header {
            width: 100%; /* 日付だけで1行になるように設定 */
            text-align: center;
            margin: 1px;
            background-color:silver;
            position:sticky;
            top:0;
        }

        .thumbnail {
            width: 33%; /* 3つの画像が並ぶように。 */
            margin: 0px 0.1% 0pt 0pt; /* rightのみ、3つで0.3%。 */
        }

        .thumbnail img {
            width: 100%; /* 画像の幅を100%に設定 */
            height: auto; /* 高さを自動調整 */
        }

        .thumbnail video {
            width: 100%; /* 画像の幅を100%に設定 */
            height: auto; /* 高さを自動調整 */
        }

        .date-list {
            width:80pt;
            position: fixed;
            right: 0;
            top: 20px;
            bottom: 20px;
            padding: 0;
            margin:0;
            background-color: #f0f0f0;
            overflow-y: auto; /* スクロール可能に設定 */
            max-height: 100vh; /* 画面の高さいっぱいにスクロールできるように設定 */
        }

        .date-list a {
            display: block;
            margin-bottom: 0;
            text-decoration: none;
            color: #333;
        }

        .date-list a.current {
            color: red; /* 強調表示の色（適宜変更してください） */
        }

        .date-list-header{
            position:fixed;
            right:0;
            top:0;
            background-color: #f0f0f0;
        }

        .date-list-header a{
            display: block;
            margin-bottom: 0;
            text-decoration: none;
            color: #333;
        }

        .date-list-footer{
            position:fixed;
            right:0;
            bottom:0;
            background-color: #f0f0f0;
        }

        .date-list-footer a{
            display: block;
            margin-bottom: 0;
            text-decoration: none;
            color: #333;
        }


    </style>
</head>
<body>

    <div id="fullscreen-viewer">
        <img src='' id='vimg' onclick="v();">
    </div>

    <div id="gallery">
        {% set ns = namespace(imgIdx=1) %}
        {% for date in unique_dates|sort %}
        <div class="date" id="{{ date }}">
            <div class="date-header" ">{{ groupname_by_date[date] }}</div>
                {% for image in images_by_date[date] %}
                    {% if image.type=="image" %}
                        <div class="thumbnail" tabindex="0">
                            <img src="{{ image.trpath }}" loading="lazy" alt="{{ image.name }}" title="{{ image.tip }}" onclick="t({{ ns.imgIdx }});" id={{ ns.imgIdx }} orgImg="{{image.orpath}}">
                            {% set ns.imgIdx = ns.imgIdx + 1 %}
                        </div>
                    {% elif image.type=="move" %}
                        <div class="thumbnail">
                            <video src={{ image.orpath }} controls disablepictureinpicture muted preload="none" poster="{{ image.trpath }}">{{ image.name }}"</video>
                        </div>
                    {% endif %}
                {% endfor %}
            </div>
        {% endfor %}
    </div>

    <div id="dateindex">
        <div class="date-list-header">
            <a href="..\index.html">index</a>
        </div>

        <div class="date-list">
            {% for date in unique_dates|sort %} {# 日付を昇順でソート #}
                <a href="#{{ date }}">{{ date }}</a>
            {% endfor %}
        </div>
        <div class="date-list-footer">
            <a href="..\index.html">index</a>
        </div>
    </div>

    <script>
        const viewer = document.getElementById('fullscreen-viewer');
        const vimg = document.getElementById('vimg');
        const gallery = document.getElementById('gallery');
        const dateIdx = document.getElementById('dateindex');

        var imgIdx=1;
        var fitSts = 1;  //1:全画面fit , 2:画面幅fit , 3:原寸大

        window.onload = function(){
            document.getElementById(imgIdx).parentNode.focus();
        }

        //表示切替
        function viewSwitch(s){
            if(s==0){
                viewer.style.display='none';        //ビューワを消す
                dateindex.style.display='inline';   //日付リストと
                gallery.style.display='inline';     //サムネイルを表示
                //document.body.style.zoom = 1;     //【TODO】ピンチズームしてしまっているのを戻したいがコレジャナイ
                ei=document.getElementById(imgIdx); //現在の画像が
                ei.scrollIntoView();                //表示される位置にスクロール

                const tmbList = gallery.querySelectorAll('.thumbnail');
                for (let i = 0; i < tmbList.length; i++) {
                    const etmb = tmbList[i];
                    const eimg = etmb.querySelector('img');
                    if (eimg != null){
                        if ( imgIdx == eimg.getAttribute('id') ){
                            etmb.focus();
                            break;
                        }
                    }
                }

            }else{
                viewer.style.display='block';       //ビューワを表示
                currentScale = 1;
                vimg.style.transform = `scale(${currentScale})`;
                vimg.style.width='100%';
                vimg.style.height='100%';
                fitSts=1;
                viewer.style.overflow='hidden';
                viewer.style.cursor='zoom-in';
                gallery.style.display='none';
                dateindex.style.display='none';
            }
        }


        function fitSwitdh(){
            //原寸大→全画面fit
            if (fitSts == 3 ){
                vimg.style.width='100%';
                vimg.style.height='100%';
                fitSts=1;
                viewer.style.overflow='hidden';
                viewer.style.cursor='zoom-in';

            //全画面fit→横幅fit
            }else if (fitSts == 1 ){

                r1 = vimg.naturalWidth/ vimg.naturalHeight;
                r2 = vimg.offsetWidth / vimg.offsetHeight;

                //画面の横長比が、画像の縦横比より横長
                if ( r1 < r2 ){
                    vimg.style.width='100%';
                    vimg.style.height='auto';

                }else{
                    vimg.style.width='auto';
                    vimg.style.height='100%';
                }
                fitSts=2;
                viewer.style.overflow='auto';
                if (vimg.naturalWidth < viewer.offsetWidth 
                && vimg.naturalHeight < viewer.offsetHeight) {
                    viewer.style.cursor='zoom-in';
                }else{
                    viewer.style.cursor='none';
                }
                scrollImg();

            //横幅fit→原寸大
            }else if (fitSts == 2 ){
                if (vimg.naturalWidth < viewer.offsetWidth 
                && vimg.naturalHeight < viewer.offsetHeight) {
                    //画面からはみ出すことがない画像だったら、fitに戻す。
                    vimg.style.width='100%';
                    vimg.style.height='100%';
                    fitSts=1;
                    viewer.style.overflow='hidden';
                    viewer.style.cursor='zoom-in';

                }else{
                    vimg.style.width='auto';
                    vimg.style.height='auto';
                    fitSts=3;
                    viewer.style.overflow='auto';
                    //viewer.style.cursor='none';
                }
                scrollImg();
            }
        }

        // サムネクリック
        function t(i){
            viewSwitch(1);
            imgIdx=i;
            ei=document.getElementById(imgIdx);
            vimg.src = ei.getAttribute('orgImg')
        }

        //ビューワ状のマウスカーソル位置取得
        var mX;
        var mY;
        vimg.addEventListener('mousemove',function(e){
            //絶対位置
            mX = e.pageX;
            mY = e.pageY;

            //画面サイズに対する割合で保持。
            mX = mX / viewer.offsetWidth;
            mY = mY / viewer.offsetHeight;

            scrollImg();
        })

        function scrollImg(){
            //オーバーサイズ
            ow = vimg.offsetWidth - viewer.offsetWidth;
            oh = vimg.offsetHeight - viewer.offsetHeight;

            viewer.scrollLeft = Math.round(ow * mX);
            viewer.scrollTop  = Math.round(oh * mY);

            //console.log(
            //    Math.round(mX*100)   + '% / ' + Math.round(mY*100) + '%'  + ' : ' +
            //    vimg.offsetWidth + 'px ' + vimg.offsetHeight + 'px' + ' : ' +
            //    viewer.offsetWidth + 'px ' + viewer.offsetHeight + 'px' + ' : ' +
            //    viewer.scrollLeft  + ' : ' + viewer.scrollTop + ' '
            //);
        }

        //ビューワクリック
        var ccnt = 0;
        function v(){
            if (ccnt>0) {           //事前にクリック済み（ダブルクリックだった場合）
                viewSwitch(0);      //ビューワを終了
                ccnt=0;
                return;
            }

            ccnt++;                         //クリックカウント
            setTimeout(function () {        //タイマー発火時に、
                if (ccnt==1) {              //シングルクリックだった場合
                    fitSwitdh();            //ズーム切り替え
                }
                ccnt=0;
            }, 300);
        }

        //キーが押されたときにビューワの状態変更
        document.addEventListener('keydown', function(e) {
            //ビューワ表示時
            if (viewer.style.display =='block') {
                if (e.key === 'Escape' || e.key ==='Backspace' ) {
                    viewSwitch(0);
                }
                if (e.key === 'f' || e.key === 'Enter' ) {
                    fitSwitdh();
                }

                //全画面fit、または横幅fit時、左右で画像切り替え
                if (vimg.style.width == '100%') {
                    flgArrow=false;
                    if (e.key === 'ArrowLeft' && 1 < imgIdx) {
                        imgIdx--;
                        flgArrow=true;
                    }
                    if (e.key === 'ArrowRight') {
                        imgIdx++;
                        flgArrow=true;
                    }
                    ei=document.getElementById(imgIdx);
                    if (ei == null){
                        imgIdx--;
                        flgArrow=false;
                    }
                    if (flgArrow ) {
                        vimg.src = ei.getAttribute('orgImg')
                    }
                }
            //サムネ表示時
            }else{
                //Ctrl同時押し
                if (e.ctrlKey ){
                    //Ctrl+上下
                    if (e.key === 'ArrowUp' || e.key === 'ArrowDown' ) {
                        const currentDateElement = document.querySelector('.date-list a.current');  //現在の日付要素を取得

                        const dateList = document.querySelector('.date-list');                      //日付リストの
                        const dateElements = dateList.querySelectorAll('a');                        //日付けリンクの中で
                        for (let i = 0; i < dateElements.length; i++) {
                            if (dateElements[i]== currentDateElement){                              //現在の日付の
                                if(e.key ==='ArrowUp'){
                                    dateElement=dateElements[i-1];                                  //前の日または、
                                }else{
                                    dateElement=dateElements[i+1];                                  //次の日が、
                                }
                                if (dateElement != null){                                           //見つかった場合、
                                    dateElement.click();                                            //移動

                                    targetDate =dateElement.getAttribute('href').substring(1);      //移動先の日付文字列
                                    targetElement = document.getElementById(targetDate);            //該当日付のdivを取得
                                    tmbList = targetElement.querySelectorAll('.thumbnail');         //該当日付中のサムネに、
                                    for (let i = 0; i < tmbList.length; i++) {
                                        const etmb = tmbList[i];
                                        const eimg = etmb.querySelector('img');                     //フォーカス可能なimgが存在すれば、
                                        if (eimg != null){
                                            imgIdx = Number(eimg.getAttribute('id'));
                                            etmb.focus();                                           //フォーカスを移す
                                            return;
                                        }
                                    }
                                    //該当日付の中にフォーカス可能なものがなかった場合、
                                    document.activeElement.blur();    //現在のフォーカスは外す。（動画しかない日なので、マウスで操作するか、Ctrl＋上下で別の日に行く）
                                }
                                return;
                            }
                        }

                    }

                //単打
                }else{
                    //BSキーでINDEXに戻る。
                    if (e.key ==='Backspace' ) {
                        const dateList = document.querySelector('.date-list');
                        const dateElements = dateList.querySelectorAll('a');
                        const yyyy=dateElements[1].getAttribute('href').substring(1,5);

                        document.location.href = "..\\index.html#"+yyyy;
                    }

                    ei=null;
                    //フォーカスを取得
                    eh=document.activeElement;
                    if (eh != document.body){
                        ei=eh.querySelector('img');
                    }

                    //imgを持ったサムネにフォーカスがあった場合
                    if ( ei != null ) {

                        //エンターキーで表示
                        if (e.key === 'Enter' ) {
                            ei.click();
                        }

                        //左右で画像選択
                        flgArrow=false;
                        imgIdx_back = imgIdx;
                        if (e.key === 'ArrowLeft' ) {
                            flgArrow=true;
                            imgIdx--;
                        }
                        if (e.key === 'ArrowRight') {
                            flgArrow=true;
                            imgIdx++;
                        }
                        if (e.key === 'ArrowUp' ) {
                            flgArrow=true;
                            imgIdx-=3;              //3つ横並びでない個所や、動画が挟まっていると変な挙動になる。。写真メインだからしょうがないね。
                        }
                        if (e.key === 'ArrowDown' ) {
                            flgArrow=true;
                            imgIdx+=3;
                        }

                        ei=document.getElementById(imgIdx);
                        if (ei == null){
                            imgIdx = imgIdx_back;
                            flgArrow=false;
                        }
                        if(flgArrow){
                            eh=ei.parentNode;
                            eh.focus();
                        }
                    }

                }

            }
        });

        let stX =0;
        let stY =0;
        let enX =0;
        let enY =0;
        let stD =0;
        let cuD =0;
        let initialScale = 1;
        let currentScale = 1;

        function getDistance(t1 , t2){
            const dx = t1.clientX - t2.clientX;
            const dy = t1.clientY - t2.clientY;
            return Math.sqrt(dx * dx + dy * dy);
        }

        document.addEventListener('touchstart', (e) => {
            if(e.touches.length == 1){
                stX = e.touches[0].pageX;
                stY = e.touches[0].pageY;
                enX = stX;
                enY = stY;
            }
            if(e.touches.length == 2){
                stD = getDistance(e.touches[0],e.touches[1]);
                initialScale = currentScale;
            }
        })
        document.addEventListener('touchmove', (e) => {
            if(e.touches.length == 1){
                enX = e.changedTouches[0].pageX;
                enY = e.changedTouches[0].pageY;
            }
            if(e.touches.length == 2){
                cuD = getDistance(e.touches[0],e.touches[1]);
                currentScale = initialScale * (cuD / stD);
                //if (1 < currentScale ) {
                if (currentScale < 1) {
                    vimg.style.transform = `scale(${currentScale})`;
                }
                if (currentScale <0.5){
                    viewSwitch(0);
                    currentScale = 1;
                }
            }
        })
        document.addEventListener('touchend', (e) => {
            if (currentScale < 1){
                currentScale = 1;
                vimg.style.transform = `scale(${currentScale})`;
                return;
            }
            const dx = Math.abs(enX - stX)
            const dy = Math.abs(enY - stY)
            if(dy < dx && 100 < dx ){
                //if (stX < enX){
                //    console.log('右 '+ dx )
                //}else{
                //    console.log('左 ' +dx )
                //}

                //ビューワが、横幅超過せずに表示されている場合
                if (viewer.style.display =='block' && vimg.style.width == '100%') {
                    flgArrow=false;
                    if (stX < enX && 1 < imgIdx){
                        imgIdx--;
                        flgArrow=true;
                    }
                    if (enX < stX ) {
                        imgIdx++;
                        flgArrow=true;
                    }
                    ei=document.getElementById(imgIdx);
                    if (ei == null){
                        imgIdx--;
                        flgArrow=false;
                    }
                    if (flgArrow ) {
                        vimg.src = ei.getAttribute('orgImg')
                    }
                return false;
                }
            }
            if(dx < dy && 150 < dy ){
                //if (stY < enY){
                //    console.log('下 ' + dy)
                //}else{
                //    console.log('上 ' + dy)
                //}

                //ビューワが、縦幅超過せずに表示されている場合
                if (viewer.style.display =='block' && vimg.style.height == '100%') {
                    currentScale = 1;
                    viewSwitch(0);
                }
            }
        })


        // スクロール位置に応じて日付を強調表示する関数
        function highlightCurrentDate() {
            const dateList = document.querySelector('.date-list');
            const dateElements = dateList.querySelectorAll('a');

            // 現在のスクロール位置に対応する日付を探す
            let currentDate = null;
            for (let i = 0; i < dateElements.length; i++) {
                const dateElement = dateElements[i];
                const targetDate = dateElement.getAttribute('href').substring(1);   //#を除去してyyyy-mm-ddを取得
                const targetElement = document.getElementById(targetDate);          //サムネ内から、IDがyyyy-mm-ddを検索
                const rect = targetElement.getBoundingClientRect();
                if (rect.top <= 100 && rect.bottom >= 100) {                        //該当日付のサムネが表示されている
                    currentDate = targetDate;
                    break;
                }
            }

            // 全ての日付から当日(current)クラスを除去
            dateElements.forEach(element => element.classList.remove('current'));

            // 現在のスクロール位置に対応する日付にクラスを追加
            if (currentDate) {
                const currentElement = dateList.querySelector(`a[href="#${currentDate}"]`);
                currentElement.classList.add('current');
                currentElement.scrollIntoView();
            }

        }

        // ギャラリーがスクロールしたときに関数を呼び出す
        gallery.addEventListener('scroll', highlightCurrentDate);

        // ページ読み込み時にも関数を呼び出す
        window.addEventListener('load', highlightCurrentDate);
    </script>

</body>
</html>
"""

#def getSS(a,b):
def getSS(exposureTime):
    if exposureTime == None:
        return '-sec'

    a=exposureTime[0]
    b=exposureTime[1]
    c,d = a,b

    if a != 1:
        c = 1
        d = round(b / a);

    return str(c) + '/' + str(d) + 'sec'


# フォルダのパス
if len(sys.argv) != 3:
    print('対象フォルダと、サムネネイル相対パスを指定してください。')
    sys.exit()

folder_path = sys.argv[1]
if not os.path.isdir(folder_path):
    print(f'"{folder_path}"はフォルダではありません。')
    print('対象フォルダを指定してください。')
    sys.exit()

thumbnail_path = os.path.abspath(os.path.join(folder_path ,sys.argv[2]))
if not os.path.isdir(thumbnail_path):
    print(f'"{thumbnail_path}"はフォルダではありません。')
    print('サムネイルフォルダを指定してください。')
    sys.exit()

print(folder_path)

# フォルダ内の画像を取得
image_list = get_images_in_folder(folder_path)

# 画像収集結果から、撮影日時、サムネパス、画像パス、ファイル名を取得
images_with_date = []
for image_path in tqdm(image_list, desc="Processing images", unit="image"):  # tqdmを使用してプログレスバーを表示
    image_dir=os.path.dirname(image_path)
    image_file=os.path.basename(image_path)
    folder_name = os.path.basename(os.path.relpath(image_dir+r'\..'))

    try:
        #動画の場合
        if image_path.lower().endswith(('.mp4', '.mov', '.avi', '.mts')):
            filetype='move'
            #時刻＝ファイルの更新日時
            date_obj=datetime.fromtimestamp(os.path.getmtime(image_path))

            #動画にはツールチップ無し
            tipinfo = ""

            #動画ファイルの相対パス
            #img_rpath = repr(os.path.relpath(image_path,folder_path))
            img_rpath = os.path.relpath(image_path,folder_path)

        #静止画の場合
        else:
            filetype='image'

            pexif = piexif.load(image_path) #SSを分数で取得するため。。縦横はPILでとるので二本立て。

            with Image.open(image_path) as img:
                width,height= img.size
                exif = img.getexif()
                exif_dict = img._getexif()

            #時刻＝撮影時刻
            date_str = exif_dict.get(36867, # ExifのDateTimeOriginalのタグが存在しない場合は、
                    datetime.fromtimestamp(os.path.getmtime(image_path)).strftime('%Y:%m:%d %H:%M:%S') #更新日時
            )
            date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')


            #img の title に設定してツールチップを表示
            tip_list=[
                image_file,                     #ファイル名
                date_str.replace(':','/',2),    #撮影日時

                                                #縦x横 (画素数)
                str(height) + ' x ' + str(width) + ' (' +  str(round(height*width / 1000000,2)) + 'MP)'
                                                #( size exifの256,257はNone！？
                + ' ' + '{:,}'.format(round(os.path.getsize(image_path) / 1024)) + 'KB',

                                                #●mm f/● ●/●s ●iso
                str(exif_dict.get(37386,'-')) + 'mm(' + str(exif_dict.get(41989,'-')) + 'mm) '
                + 'f/' + str(exif_dict.get(33437,'-')) + ' '
                + getSS(pexif['Exif'].get(33434)) + ' '
                + 'ISO' + str(exif_dict.get(34855,'-')),
                exif.get(271,'unknown').rstrip('\x00') + ' - ' + exif.get(272,'unknown').rstrip('\x00')     #メーカー モデル
            ]

            tipinfo = '\n'.join(tip_list)

            #画像ファイルの相対パス
            #img_rpath = repr(os.path.relpath(image_path,folder_path))
            img_rpath = os.path.relpath(image_path,folder_path)

        #サムネの相対パスを取得（動画/静止画共通）
        thum_dir=image_dir.replace(folder_path,thumbnail_path)
        thum_file='tmb_' + image_file + '.webp'
        thum_path=os.path.join(thum_dir,thum_file)
        thum_rpath=os.path.relpath(thum_path,folder_path)

        images_with_date.append({
            'type': filetype,                       #タイプ
            'date': date_obj,                       #時刻
            'tip' : tipinfo,                        #ツールチップ
            'trpath': thum_rpath,                     #サムネの相対パス
            'orpath': img_rpath,                     #実画像の相対パス
            'name': os.path.basename(image_path),   #ファイル名
            'dirname': folder_name
        })
    except (AttributeError, ValueError, OSError):
        pass
    except Exception as other:
        print('\n')
        print(other)
        print(image_path)
        sys.exit()

# 撮影日時でソート
images_with_date.sort(key=lambda x: x['date'])

# 重複なしの日付リストを作成
unique_dates = list(set(date.strftime('%Y-%m-%d') for date in [image['date'] for image in images_with_date]))

# 日付ごとに画像をグループ化するディクショナリ
images_by_date = {date: [] for date in unique_dates}
for image in images_with_date:
    images_by_date[image['date'].strftime('%Y-%m-%d')].append(image)

# 日付ごとのグループ名のディクショナリ
groupname_by_date = {date: [] for date in unique_dates}
for image in images_with_date:
    #print(f'{image['date'].strftime('%Y-%m-%d')}:{image['dirname']}')
    #print(groupname_by_date[image['date'].strftime('%Y-%m-%d')])
    if not groupname_by_date[image['date'].strftime('%Y-%m-%d')]:
        if image['dirname'] != '':
            groupname_by_date[image['date'].strftime('%Y-%m-%d')] = image['dirname']


# HTMLテンプレートに渡すデータを作成
template_data = {
    'unique_dates': unique_dates,
    'images_by_date': images_by_date,
    'groupname_by_date': groupname_by_date,
}

# テンプレートをレンダリング
template = Template(template_str)
html_output = template.render(**template_data)

# HTMLファイルに保存
output_path = os.path.join(folder_path,'gallery.html')
with open(output_path, 'w', encoding='utf-8') as html_file:
    html_file.write(html_output)

print(f"Gallery HTML file generated at: {output_path}")
```
</details>

## ギャラリーのフォルダ構成
さすがに400GBのJPGを一つの`gallery.html`にするのは現実的ではありませんでしたので、年毎に`gallery.html`を作って、それらをつなぎ合わせる`index.html`を一個手作りすることにしました。

```
D:\デジカメ(原本)\データ\日付別
├\2009
│　├\yyyymmdd
│　├\：
│　└gallery.html
├\2010
│　├\yyyymmdd
│　├\：
│　└gallery.html
：
├\2024
│　├\yyyymmdd
│　├\：
│　└gallery.html
│
├index.html　　　←これは手作りした。
│
└\thumbnail
```


`index.html`はこんな感じでおｋ。
新しい年が増えたら、手で増やすのです。
```index.html
<html>
    <body style="text-align: center;">
        <a href="./2009/gallery.html" id="2009">2009</a><br>
        <a href="./2010/gallery.html" id="2010">2010</a><br>
        <a href="./2011/gallery.html" id="2011">2011</a><br>
        <a href="./2012/gallery.html" id="2012">2012</a><br>
        <a href="./2013/gallery.html" id="2013">2013</a><br>
        <a href="./2014/gallery.html" id="2014">2014</a><br>
        <a href="./2015/gallery.html" id="2015">2015</a><br>
        <a href="./2016/gallery.html" id="2016">2016</a><br>
        <a href="./2017/gallery.html" id="2017">2017</a><br>
        <a href="./2018/gallery.html" id="2018">2018</a><br>
        <a href="./2019/gallery.html" id="2019">2019</a><br>
        <a href="./2020/gallery.html" id="2020">2020</a><br>
        <a href="./2021/gallery.html" id="2021">2021</a><br>
        <a href="./2022/gallery.html" id="2022">2022</a><br>
        <a href="./2023/gallery.html" id="2023">2023</a><br>
        <a href="./2024/gallery.html" id="2024">2024</a><br>
    </body>
    <script>
        window.onload = function(){
            i=document.location.hash.substring(1,5);
            document.getElementById(i).focus();
        }

        document.addEventListener('keydown', function(e) {
            i=document.activeElement.getAttribute('id');
            if (e.key === 'ArrowUp' ) {
                i--;
            }
            if (e.key === 'ArrowDown' ) {
                i++;
            }
            document.getElementById(i).focus();
        });
    </script>
</html>
```


## ギャラリー作成の動かし方
以下のように、バッチファイルから年単位で起動します。
サムネは「日付別」直下にひと固まりで置いてあるので、相対パスで指定しています。
呼び出した単位で`gallery.html`が作成されるので、年単位の写真フォルダが増えるたびに呼び出しも1行増やすのです。

```.bat
@echo off
set BASE_DIR=D:\デジカメ(原本)\データ\日付別

rem SSDならstartで多重で実施したほうが早いが、HDDならシリアルに実施したほうが早いかも。
python gallery_generator.py %BASE_DIR%\2009 ..\thumbnail\2009
python gallery_generator.py %BASE_DIR%\2010 ..\thumbnail\2010
python gallery_generator.py %BASE_DIR%\2011 ..\thumbnail\2011
python gallery_generator.py %BASE_DIR%\2012 ..\thumbnail\2012
python gallery_generator.py %BASE_DIR%\2013 ..\thumbnail\2013
python gallery_generator.py %BASE_DIR%\2014 ..\thumbnail\2014
python gallery_generator.py %BASE_DIR%\2015 ..\thumbnail\2015
python gallery_generator.py %BASE_DIR%\2016 ..\thumbnail\2016
python gallery_generator.py %BASE_DIR%\2017 ..\thumbnail\2017
python gallery_generator.py %BASE_DIR%\2018 ..\thumbnail\2018
python gallery_generator.py %BASE_DIR%\2019 ..\thumbnail\2019
python gallery_generator.py %BASE_DIR%\2020 ..\thumbnail\2020
python gallery_generator.py %BASE_DIR%\2021 ..\thumbnail\2021
python gallery_generator.py %BASE_DIR%\2022 ..\thumbnail\2022
python gallery_generator.py %BASE_DIR%\2023 ..\thumbnail\2023
python gallery_generator.py %BASE_DIR%\2024 ..\thumbnail\2024
```

実際の運用としては、写真整理をしてから、サムネを作ってから、ギャラリーを作る、ミラーリングをする。という順番になります。


# 閲覧方法


一切解説をしませんでしたが、一応、JavaScriptで以下のような制御を行っています。
ベッタベタのベタ書きなので、カスタマイズ性などは一切ありません。
- サムネ一覧で、右端の日付リストをクリックするとそこまでジャンプします。
- サムネ一覧で、Ctrl+上下カーソルを打つと、日付単位にジャンプします。
- サムネ一覧で、サムネ画像をホバーするとExif情報がポップアップされます。
- サムネ一覧で、BackSpaceキーを打つと`../index.html`に遷移します。遷移後の年の一覧では上下カーソルで選択可に。
- サムネ一覧で画像をクリックすると、全画面表示します。
- 全画面表示中は、左右カーソル、左右スワイプで、前後の画像ファイルに切り替わります。（左右スクロールバーがない時）
- 全画面表示中は、クリックするたびに、全画面fit→縦横最大fit→原寸大に切り替わります。
全画面fitよりも拡大して表示しているときは、マウスで画像をスクロールします。
- 全画面表示中は、ESCキー、ダブルクリック、上下スワイプ、ピンチアウトで、サムネ一覧に戻ります。

サムネ一覧の上下左右カーソルでフォーカスを動かすようにしてある（動画ファイルは除外）ので、好みの問題かもしれませんが、個人的には`edge://flags/`で`Smooth Scrolling`を Disabled にしておくのがオススメです。

# 反省点（のようなもの）
日付ごとの仕切りにフォルダ名を表示するようにしてあるのですが、所定の階層構造で格納していないと、変な表示になったりすることがあります。
年単位に`gallery.html`を作る、という方針に転換してから、うっかり2階層上を決め打ちするようにしてしまいました。（写真整理フォルダに対して使うだけなので、あんまり気にはしていないです。）

html,css,js などに分かれていないのが気持ちわるい…ような気もしますが、それぞれ別個にメンテナンスすることはなく、修正が必要な場合は pythonスクリプトを修正して一斉に`gallery.html`を作り直すので、これはこれで問題ないかなと思っています。サーバにアップロードするわけでなし。

コード中の命名規約がブレブレになってしまいました。ChatGPG由来のコードとググった参考元の記述と、自分で書いた記述がぐちゃぐちゃに混ざっているからです。
・・・動けばいいのです。動けば。

# その後
[後日談](https://qiita.com/azuwai2/items/ff4a59922cde56e78ceb)追加しました。

# 免責事項
もしご使用になるときは、自己責任にてお願いいたします。