UJP - 写真,360度全天球写真,動画ファイルを1つのフォルダに入れてアルバムページを作る

Life is fun and easy!

不正IP報告数

Okan Sensor
 
メイン
ログイン
ブログ カテゴリ一覧

ブログ - 写真,360度全天球写真,動画ファイルを1つのフォルダに入れてアルバムページを作る

写真,360度全天球写真,動画ファイルを1つのフォルダに入れてアルバムページを作る

カテゴリ : 
ハウツー
ブロガー : 
ujpblog 2026/4/13 0:46
 件名にある通り,1つのフォルダに写真,360度全天球写真,動画ファイルを入れて,そのフォルダを指定して実行すると,アルバムを作成してくれるツール.Microsoft Copilotを使って作りました.

 こんな感じで使います.
$ python3 generate_viewer.py -f 20260409SunriseIzumo -o ¥ 
20260409SunriseIzumo.html -t サンライズ出雲乗車記録2026.04.09_150分おくれ
$

-f メディアの入ったフォルダ
-o HTMLファイル名
-t ページタイトル
 -fで指定されたフォルダに,サムネイルファイルや生成されたHTMLファイルが入ります.
 -oで生成されたファイルをブラウザで読み込むとアルバムとして使えます.
 写真の説明は,ファイル名から取得する仕様なので,ファイル名に長く記載してください.ファイルの掲載順は,ファイルのタイムスタンプです.

 iPhoneの写真をmacOSにAirDropするとHEIFファイルになりますが,そのままだとうまくサムネイルが作成できないので,Finderで「イメージ変換」してJPEGにしてください.画像サイズは「実際のサイズ」で良いかと思います.
 以下のプログラムは,macOS Montereyで動作確認していますが,RICOH THETAで撮影した360度写真と共に写真アルバムを作るにあるPannellumを使っています.

 作ったものはこんな感じ.

サンライズ出雲乗車記録2026.04.09_150分おくれ

$ cat generate_viewer.py🆑
#!/usr/bin/env python3
import argparse
from pathlib import Path
import subprocess
import json
from datetime import datetime, timezone, timedelta
import urllib.request
import urllib.parse
from collections import OrderedDict

# ==========================
# 共通
# ==========================
created_date = datetime.now().strftime("%Y年%m月%d日")
geocode_cache = {}
JST = timezone(timedelta(hours=9))

# ==========================
# 逆ジオコーディング
# ==========================
def reverse_geocode(lat, lon):
    key = f"{lat},{lon}"
    if key in geocode_cache:
        return geocode_cache[key]
    try:
        url = "https://nominatim.openstreetmap.org/reverse?" + urllib.parse.urlencode({
            "lat": lat,
            "lon": lon,
            "format": "json",
            "zoom": 16,
            "addressdetails": 1
        })
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=5) as res:
            data = json.loads(res.read().decode("utf-8"))
            addr = data.get("address", {})
            geocode_cache[key] = addr
            return addr
    except:
        geocode_cache[key] = {}
        return {}

def extract_city(addr):
    return (
        addr.get("city") or
        addr.get("town") or
        addr.get("village") or
        addr.get("municipality") or
        addr.get("ward") or
        addr.get("city_district") or
        addr.get("county") or
        addr.get("suburb") or
        ""
    )

def format_location_short(addr):
    if not addr:
        return "ー"
    state = addr.get("state", "")
    city = extract_city(addr)
    if state and city:
        return f"{state} {city}"
    elif state:
        return state
    elif city:
        return city
    else:
        return "ー"

def format_location_full(addr):
    if not addr:
        return "ー"
    state = addr.get("state", "")
    city = extract_city(addr)
    detail_parts = []
    for key in ["suburb", "neighbourhood", "hamlet", "quarter", "road", "house_number"]:
        if addr.get(key):
            detail_parts.append(addr[key])
    detail = " ".join(detail_parts)
    if state and city:
        base = f"{state} {city}".strip()
    elif state:
        base = state
    elif city:
        base = city
    else:
        base = ""
    if base and detail:
        return f"{base} {detail}".strip()
    elif base:
        return base
    elif detail:
        return detail
    else:
        return "ー"
# ==========================
# 日時関連
# ==========================
def parse_dt(dt_str):
    try:
        return datetime.strptime(dt_str, "%Y:%m:%d %H:%M:%S")
    except:
        return None

def format_datetime(dt_str):
    try:
        dt = datetime.strptime(dt_str, "%Y:%m:%d %H:%M:%S")
        return dt.strftime("%Y年%m月%d日 %H:%M:%S")
    except:
        return "ー"

def parse_creation_time(s):
    if not s:
        return None
    try:
        return datetime.fromisoformat(s.replace("Z", "+00:00"))
    except:
        return None

def format_datetime_from_dt(dt):
    if not dt:
        return "ー"
    return dt.strftime("%Y年%m月%d日 %H:%M:%S")

def format_date_only_from_dt(dt):
    if not dt:
        return "その他"
    return dt.strftime("%Y年%m月%d日")

# ==========================
# EXIF / 動画メタデータ
# ==========================
def get_exif(path):
    result = subprocess.run(
        ["exiftool", "-json", "-n", str(path)],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True
    )
    return json.loads(result.stdout)[0]

def get_video_metadata(path):
    cmd = [
        "ffprobe", "-v", "quiet", "-print_format", "json",
        "-show_format", "-show_streams", str(path)
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True)
    if not result.stdout.strip():
        return {}
    return json.loads(result.stdout)

def parse_video_location(loc):
    if not loc:
        return None, None
    try:
        loc = loc.strip("/")
        if loc[0] in "+-":
            sign = loc[0]
            rest = loc[1:]
            parts = rest.split("+")
            if len(parts) >= 2:
                lat = float(sign + parts[0])
                lon = float("+" + parts[1])
                return lat, lon
    except:
        pass
    return None, None

def format_duration(sec):
    try:
        sec = int(float(sec))
        m = sec // 60
        s = sec % 60
        return f"{m:02d}:{s:02d}"
    except:
        return "ー"

# ==========================
# HTML テンプレート
# ==========================
INDEX_HEAD = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>{page_title}</title>
<style>
body {{
  font-family: sans-serif;
  background: #fafafa;
  margin: 0;
}}
header {{
  text-align: center;
  padding: 20px;
}}
h1 {{
  margin: 10px 0 0 0;
  font-size: 26px;
}}
h3 {{
  margin: 5px 0 10px 0;
  font-size: 16px;
  color: #555;
}}
.topnav {{
  text-align: center;
  margin: 10px 0;
  font-size: 18px;
}}
.section-title {{
  max-width: 1200px;
  margin: 10px auto 0 auto;
  padding: 0 20px;
  font-size: 18px;
  font-weight: bold;
}}
.count-info {{
  text-align: center;
  font-size: 14px;
  color: #555;
  margin-bottom: 10px;
}}
.grid {{
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 20px;
  padding: 10px 20px 20px 20px;
  max-width: 1200px;
  margin: 0 auto 20px auto;
}}
.card {{
  background: white;
  border-radius: 8px;
  padding: 10px;
  box-shadow: 0 0 6px rgba(0,0,0,0.2);
  text-align: center;
}}
.thumb-img {{
  width: 100%;
  height: auto;
  border-radius: 6px;
}}
.filename {{
  text-align: left;
  font-size: 14px;
  margin-top: 5px;
}}
.datetime {{
  text-align: left;
  font-size: 12px;
  color: #666;
}}
.location {{
  text-align: left;
  font-size: 12px;
  color: #444;
  margin-top: 3px;
}}
.map-link {{
  text-align: left;
  font-size: 12px;
  margin-top: 3px;
}}
.map-link a {{
  text-decoration: none;
  color: #1a73e8;
}}
.badge {{
  display: inline-block;
  padding: 2px 6px;
  font-size: 10px;
  border-radius: 4px;
  background: #eee;
  margin-left: 4px;
}}
.badge-360 {{
  background: #4caf50;
  color: #fff;
}}
.badge-video {{
  background: #f44336;
  color: #fff;
}}
</style>
</head>
<body>

<div class="topnav">
  <a href="../index.html">↑ 1つ上に戻る</a>
</div>

<header>
  <hr>
  <h1>{page_title}</h1>
  <h3>作成日:{created_date}</h3>
  <div class="count-info">全 {total_count} 件</div>
  <hr>
</header>
"""
INDEX_FOOT = """
</body>
</html>
"""

VIEWER_HEAD = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>{title}</title>

<link rel="stylesheet" href="../pannellum.css">
<script src="../pannellum.js"></script>

<style>
body {{
  margin: 0;
  font-family: sans-serif;
  background: #fafafa;
}}
header {{
  text-align: center;
  padding: 20px;
}}
h1 {{
  margin: 10px 0 0 0;
  font-size: 26px;
}}
h3 {{
  margin: 5px 0 10px 0;
  font-size: 16px;
  color: #555;
}}
.pano {{
  width: 100%;
  height: 500px;
}}
.info-table {{
  margin: 20px auto;
  width: 90%;
  max-width: 700px;
  border-collapse: collapse;
}}
.info-table td {{
  border: 1px solid #ccc;
  padding: 8px;
}}
.info-table td:first-child {{
  width: 30%;
  background: #f0f0f0;
  font-weight: bold;
}}
.nav {{
  margin: 20px 0;
  font-size: 22px;
  text-align: center;
}}
.nav a {{
  margin: 0 25px;
  text-decoration: none;
}}
.map-btn {{
  display: inline-block;
  padding: 10px 20px;
  background: #4285F4;
  color: white;
  border-radius: 6px;
  text-decoration: none;
  font-size: 16px;
}}
</style>
</head>
<body>

<header>
  <hr>
  <h1>{title}</h1>
  <h3>作成日:{created_date}</h3>
  <hr>
</header>

<div class="nav">
  {nav_top}
</div>
"""

VIEWER_FOOT = """
</body>
</html>
"""

# ==========================
# メイン処理
# ==========================
def main():
    parser = argparse.ArgumentParser(description="THETA 360° / 写真 / 動画 ビューア生成(HEIC変換 + 動画メタデータ + JST対応)")
    parser.add_argument("-f", "--folder", required=True)
    parser.add_argument("-o", "--output", required=True)
    parser.add_argument("-t", "--title", required=True)
    args = parser.parse_args()

    folder = Path(args.folder).resolve()

    image_ext = {".jpg", ".jpeg", ".JPG", ".JPEG", ".png", ".PNG", ".heic", ".HEIC"}
    video_ext = {".mp4", ".mov", ".m4v", ".webm", ".MP4", ".MOV", ".M4V", ".WEBM"}

    files = []
    for p in sorted(folder.iterdir()):
        if p.suffix in image_ext or p.suffix in video_ext:
            files.append(p)

    items = []

    # ==========================
    # ファイルごとの処理
    # ==========================
    for f in files:
        ext = f.suffix
        lower_ext = f.suffix.lower()
        is_video = lower_ext in {e.lower() for e in video_ext}
        is_heic = lower_ext in {".heic"}

        # HEIC → JPEG 変換(元ファイルは残す)
        if is_heic:
            jpg_path = f.with_suffix(".jpg")
            if not jpg_path.exists():
                subprocess.run(
                    ["ffmpeg", "-y", "-i", str(f), str(jpg_path)],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL
                )
            f = jpg_path
            lower_ext = ".jpg"
            is_video = False

        exif = {}
        dt = None
        dt_raw = None
        camera_model = "ー"
        lat = None
        lon = None
        projection = None

        video_meta = {}
        video_duration = "ー"
        video_codec = "ー"
        video_resolution = "ー"

        # ==========================
        # 写真(EXIF)
        # ==========================
        if not is_video:
            exif = get_exif(f)
            dt_raw = exif.get("DateTimeOriginal")
            dt = parse_dt(dt_raw) if dt_raw else None
            camera_model = exif.get("Model", "ー")
            lat = exif.get("GPSLatitude")
            lon = exif.get("GPSLongitude")
            projection = exif.get("ProjectionType")

        # ==========================
        # 動画(ffprobe)
        # ==========================
        else:
            video_meta = get_video_metadata(f)
            fmt = video_meta.get("format", {})
            streams = video_meta.get("streams", [])

            tags = fmt.get("tags", {}) if isinstance(fmt, dict) else {}
            creation_time = tags.get("creation_time")
            dt = parse_creation_time(creation_time)

            # ★ UTC → JST → naive
            if dt and dt.tzinfo:
                dt = dt.astimezone(JST)
            if dt:
                dt = dt.replace(tzinfo=None)

            dt_raw = creation_time

            camera_model = tags.get("com.apple.quicktime.model") or tags.get("model") or "ー"

            loc = tags.get("location")
            if loc:
                lat, lon = parse_video_location(loc)

            dur = fmt.get("duration")
            if dur:
                video_duration = format_duration(dur)

            if streams:
                vstream = None
                for s in streams:
                    if s.get("codec_type") == "video":
                        vstream = s
                        break
                if not vstream:
                    vstream = streams[0]

                codec_name = vstream.get("codec_name")
                if codec_name:
                    video_codec = codec_name

                w = vstream.get("width")
                h = vstream.get("height")
                if w and h:
                    video_resolution = f"{w}×{h}"

        items.append({
            "path": f,
            "is_video": is_video,
            "exif": exif,
            "dt_raw": dt_raw,
            "dt": dt,
            "camera_model": camera_model,
            "lat": lat,
            "lon": lon,
            "projection": projection,
            "video_duration": video_duration,
            "video_codec": video_codec,
            "video_resolution": video_resolution,
        })
    # ==========================
    # 日付順にソート
    # ==========================
    items.sort(key=lambda x: x["dt"] if x["dt"] else datetime.max)

    # ==========================
    # サムネイル生成フォルダ
    # ==========================
    thumb_dir = folder / "thumb"
    thumb_dir.mkdir(exist_ok=True)

    index_html = folder / args.output
    total_count = len(items)
    index_parts = [
        INDEX_HEAD.format(
            page_title=args.title,
            created_date=created_date,
            total_count=total_count
        )
    ]

    # ==========================
    # 日付グループ化
    # ==========================
    groups = OrderedDict()
    for idx, item in enumerate(items):
        dt = item["dt"]
        if dt:
            date_key = dt.strftime("%Y-%m-%d")
            date_label = format_date_only_from_dt(dt)
        else:
            date_key = "other"
            date_label = "その他"

        if date_key not in groups:
            groups[date_key] = {"label": date_label, "items": []}

        groups[date_key]["items"].append((idx, item))

    total = len(items)

    # ==========================
    # 個別ページ生成
    # ==========================
    for i, item in enumerate(items):
        f = item["path"]
        is_video = item["is_video"]
        dt = item["dt"]
        dt_raw = item["dt_raw"]
        camera_model = item["camera_model"]
        lat = item["lat"]
        lon = item["lon"]
        projection = item["projection"]
        video_duration = item["video_duration"]
        video_codec = item["video_codec"]
        video_resolution = item["video_resolution"]

        viewer_name = f"viewer_{i+1:04d}.html"
        viewer_path = folder / viewer_name

        # --------------------------
        # サムネイル生成
        # --------------------------
        thumb_name = f"thumb_{i+1:04d}.jpg"
        thumb_path = thumb_dir / thumb_name

        if not thumb_path.exists():
            if not is_video:
                subprocess.run(
                    ["convert", str(f), "-resize", "300x300", str(thumb_path)]
                )
            else:
                subprocess.run(
                    [
                        "ffmpeg", "-y",
                        "-i", str(f),
                        "-ss", "00:00:01",
                        "-vframes", "1",
                        "-vf", "scale=300:-1",
                        str(thumb_path)
                    ],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL
                )

        # --------------------------
        # 日付表示
        # --------------------------
        if dt_raw and isinstance(dt_raw, str) and ":" in dt_raw and dt_raw.count(":") == 2 and "T" not in dt_raw:
            dt_html_full = format_datetime(dt_raw)
        else:
            dt_html_full = format_datetime_from_dt(dt)

        # --------------------------
        # 位置情報
        # --------------------------
        if lat and lon:
            addr = reverse_geocode(lat, lon)
            location_short = format_location_short(addr)
            location_full = format_location_full(addr)
            lat_str = str(lat)
            lon_str = str(lon)
        else:
            location_short = "ー"
            location_full = "ー"
            lat_str = ""
            lon_str = ""

        # --------------------------
        # ナビゲーション
        # --------------------------
        prev_link = f"viewer_{i:04d}.html" if i > 0 else ""
        next_link = f"viewer_{i+2:04d}.html" if i < total - 1 else ""

        nav_html = ""
        nav_html += f'<a href="{prev_link}">←</a>' if prev_link else '←'
        nav_html += f'<a href="{args.output}">TOPに戻る</a>'
        nav_html += f'<a href="{next_link}">→</a>' if next_link else '→'

        prev_js = f'window.location.href="{prev_link}";' if prev_link else ''
        next_js = f'window.location.href="{next_link}";' if next_link else ''

        comment_name = f.stem

        # --------------------------
        # Google Map ボタン
        # --------------------------
        if lat_str and lon_str:
            map_button_html = f"""
<div style="text-align:center; margin:20px;">
  <a class="map-btn" href="https://www.google.com/maps?q={lat_str},{lon_str}" target="_blank">
    Google Map で開く
  </a>
</div>
"""
        else:
            map_button_html = ""

        # --------------------------
        # 表示方式(360° / 写真 / 動画)
        # --------------------------
        ext = f.suffix.lower()
        video_ext_lower = {".mp4", ".mov", ".m4v", ".webm"}

        if projection == "equirectangular" and not is_video:
            viewer_html = f"""
<div id="panorama" class="pano"></div>

<script>
pannellum.viewer('panorama', {{
  type: 'equirectangular',
  panorama: '{f.name}',
  autoLoad: true,
  hfov: 120
}});
</script>
"""
        elif ext in video_ext_lower:
            viewer_html = f"""
<div style="text-align:center; margin:20px;">
  <video controls style="max-width:100%; border-radius:8px;">
    <source src="{f.name}" type="video/mp4">
    お使いのブラウザは動画再生に対応していません。
  </video>
</div>
"""
        else:
            viewer_html = f"""
<div style="text-align:center; margin:20px;">
  <img src="{f.name}" style="max-width:100%; height:auto; border-radius:8px;">
</div>
"""

        # --------------------------
        # 個別ページ HTML 書き込み
        # --------------------------
        with open(viewer_path, "w", encoding="utf-8") as vf:
            vf.write(VIEWER_HEAD.format(
                title=args.title,
                created_date=created_date,
                nav_top=nav_html
            ))

            vf.write(viewer_html)

            vf.write(f"""
<script>
document.addEventListener('keydown', function(e) {{
    if (e.key === "ArrowLeft") {{
        {prev_js}
    }}
    if (e.key === "ArrowRight") {{
        {next_js}
    }}
}});
</script>

<table class="info-table">
  <tr><td>撮影日</td><td>{dt_html_full}</td></tr>
  <tr><td>撮影場所</td><td>{location_full}</td></tr>
  <tr><td>カメラ</td><td>{camera_model}</td></tr>
  <tr><td>コメント</td><td>{comment_name}</td></tr>
""")

            if is_video:
                vf.write(f"""
  <tr><td>動画の長さ</td><td>{video_duration}</td></tr>
  <tr><td>コーデック</td><td>{video_codec}</td></tr>
  <tr><td>解像度</td><td>{video_resolution}</td></tr>
""")

            vf.write("</table>\n")
            vf.write(map_button_html)

            vf.write(f"""
<div class="nav">
  {nav_html}
</div>
""")

            vf.write(VIEWER_FOOT)

        # --------------------------
        # 一覧ページ用データ保存
        # --------------------------
        item["viewer_name"] = viewer_name
        item["thumb_name"] = thumb_name
        item["dt_html_full"] = dt_html_full
        item["location_short"] = location_short
        item["lat_str"] = lat_str
        item["lon_str"] = lon_str

        if projection == "equirectangular" and not is_video:
            item["badge"] = "360"
        elif is_video:
            item["badge"] = "video"
        else:
            item["badge"] = "photo"

    # ==========================
    # 一覧ページ生成
    # ==========================
    for date_key, group in groups.items():
        label = group["label"]
        index_parts.append(f'<div class="section-title">{label}</div>\n')
        index_parts.append('<div class="grid">\n')

        for idx, _ in group["items"]:
            img_item = items[idx]
            viewer_name = img_item["viewer_name"]
            thumb_name = img_item["thumb_name"]
            dt_html_full = img_item["dt_html_full"]
            location_short = img_item["location_short"]
            lat_str = img_item["lat_str"]
            lon_str = img_item["lon_str"]
            short_comment = img_item["path"].stem

            if len(short_comment) > 15:
                short_comment = short_comment[:15] + "…"

            if lat_str and lon_str:
                map_link_html = f'<div class="map-link"><a href="https://www.google.com/maps?q={lat_str},{lon_str}" target="_blank">Google Map</a></div>'
            else:
                map_link_html = ""

            if img_item["badge"] == "360":
                badge_html = '<span class="badge badge-360">360°</span>'
            elif img_item["badge"] == "video":
                badge_html = '<span class="badge badge-video">動画</span>'
            else:
                badge_html = '<span class="badge">写真</span>'

            index_parts.append(f"""
<div class="card">
  <a href="{viewer_name}">
    <img class="thumb-img" src="thumb/{thumb_name}" alt="{img_item['path'].name}">
  </a>
  <div class="datetime">{dt_html_full}</div>
  <div class="location">{location_short} {badge_html}</div>
  {map_link_html}
  <div class="filename">{short_comment}</div>
</div>
""")

        index_parts.append("</div>\n")

    index_parts.append(INDEX_FOOT)

    # ==========================
    # 一覧ページ書き込み
    # ==========================
    with open(index_html, "w", encoding="utf-8") as f:
        f.write("".join(index_parts))

    print("サムネイル・一覧ページ:", index_html)
    print("個別ビューア / プレイヤー:", total_count, "件生成しました。")


if __name__ == "__main__":
    main()

トラックバック


広告スペース
Google