プログラミング

Immichの連写写真を自動でまとめるツールを作りました(immich-burst-stacker)

ディズニーで増えた連写写真を、撮影時刻とphashで自動判定してImmichのスタックにまとめるPython CLIの仕組みを紹介します。実コードの抜粋、安全なscan→確認→applyの流れ、22,431枚から2,351スタックを検出した結果までまとめました。

目次

はじめに

テーマパークで撮った連写写真が何枚も並び、あとから見返しにくいと感じたことはありませんか。
私はディズニーのショーやグリーティングでよく連写するので、似た写真がどんどん増えていきます。
Google フォトを使っていた頃は似た写真がまとまって見えていましたが、私の Immich v2.5.3 の環境へ移してからは1枚ずつ並んで表示され、ギャラリーの見通しが悪くなりました。
そこで、撮影時刻と画像の見た目を使って連写を見つけ、Immich のスタックにまとめる Python CLI「immich-burst-stacker」を作りました。
この記事では、判定の仕組み、実コードの抜粋、安全に適用する流れ、実際のライブラリで動かした結果まで紹介します。
コードは雰囲気だけ眺めて読み飛ばしても話がつながるように書きますので、仕組みだけ知りたい方もそのまま読み進めて大丈夫です。

このツールで何が変わるか

まずは同じ日のタイムラインを、処理の前後で比べてみます。
ここで使っているのは、記事用に用意したサンプル写真です。

Immich のタイムラインに連写を含む45枚の写真が並んでいる処理前の画面

処理前は、連写5組と単発写真5枚の合計45枚が、そのままタイムラインに並んでいます。
写真を1枚ずつ確認しながら手でまとめることもできますが、枚数が増えるとかなり面倒です。

連写がスタックにまとまり45枚が10タイルになった Immich のタイムライン

処理後は、連写が8枚・12枚・4枚・6枚・10枚のスタックになり、右上に枚数のバッジが付きました。
45枚の写真は、タイムライン上では5つのスタックと5枚の単発写真、合計10タイルに収まっています。

なぜ自作したか

私が欲しかったのは、単に似た写真をまとめる機能ではありません。
自分で判定値を調整でき、実際に書き込む前に候補を確認できることが重要でした。

手段 私の環境での見え方・操作 調整 適用前の確認
Google フォトを使っていた頃 似た写真がまとまって見えていました 自分で何かを設定した覚えはありません 候補を確認する場面はありませんでした
Immich v2.5.3 のスタック 写真を手で選んでスタックを作れます まとめる写真を自分で選べます 選択した写真を画面で確認できます
immich-burst-stacker 時刻と phash で候補をまとめます 時間窓と距離しきい値を変更できます scan のレポートと proposals.json を確認できます

連写の間隔はカメラや撮り方で変わりますし、どこまで似ていたら同じ場面とみなすかも人によって違います。
そのため、時間窓を TIME_WINDOW_SECONDS、見た目の近さを PHASH_DISTANCE_THRESHOLD として外から変えられるようにしました。
さらに、検出と書き込みを scan と apply に分け、候補を目で確認してから進める構成にしています。

Immich の「スタック」とは

Immich v2.5.3 のスタックは、複数の写真をひとまとまりにして扱う機能です。
スタックには代表として表示される primary の写真があり、残りの写真はその中にまとまります。
タイムラインでは primary の1枚に畳まれ、右上のバッジでスタック内の枚数が分かります。
スタックを開けば、中に入っている写真を個別に見られます。

API から作る場合は、POST /stacks に {"assetIds": [...]} を送ります。
最低2件のアセットIDが必要で、配列の先頭が primary です。
このツールでは候補を撮影時刻順に並べているため、各連写の先頭写真がそのまま primary になります。

仕組みの全体像

全体の流れは、「探す・確認する・作る」の3段階です。

Immich v2.5.3
    │
    ├─ scan
    │   ├─ 全画像を撮影時刻順に並べる
    │   ├─ 2秒以内で時間クラスタを作る
    │   ├─ サムネイルから phash を計算する
    │   ├─ 距離10以内の写真をグループ化する
    │   └─ proposals.json とレポートを出す
    │
    ├─ 人が proposals.json を確認する
    │   └─ 不要なグループは削除する
    │
    └─ apply
        ├─ POST /stacks でスタックを作る
        └─ 1グループごとに state.json を保存する

scan は書き込まず、候補に納得できたときだけ apply がスタック作成の API を呼びます。

詳細1:撮影時刻でまとめる

最初に必要なのは、各写真の撮影時刻です。
api.py の asset_taken_at() は exifInfo.dateTimeOriginal を優先し、無ければ fileCreatedAt にフォールバックします。
どちらも parse_timestamp() で読み、タイムゾーンが無ければUTCとして扱い、最後にUTCへそろえます。
両方を取得できない写真は、順番を決められないため候補から外します。

時刻がそろったら、clustering.py の build_time_clusters() で時間クラスタを作ります。
実装は次のとおりです。

def build_time_clusters(
    assets: Iterable[AssetInfo], time_window_seconds: float
) -> list[list[AssetInfo]]:
    # ...(省略)
    ordered = sorted(assets, key=lambda a: (a.taken_at, a.id))
    if not ordered:
        return []

    clusters: list[list[AssetInfo]] = [[ordered[0]]]
    for prev, current in zip(ordered, ordered[1:]):
        delta = (current.taken_at - prev.taken_at).total_seconds()
        if delta <= time_window_seconds:
            clusters[-1].append(current)
        else:
            clusters.append([current])
    return clusters

既定の TIME_WINDOW_SECONDS=2 は、隣り合う2枚の差に対する上限です。
最初の写真から最後の写真までが2秒以内、という意味ではありません。
たとえば各写真が1.5秒間隔なら、6枚全体では7.5秒に広がっても、隣同士はすべて2秒以内なので同じクラスタになります。
同じ時刻ならアセットIDもキーにして結果を固定し、2秒は同じクラスタ、2.001秒は別クラスタになることをテストしています。

詳細2:phashで見た目を比べる

時間だけで判定すると、短い間隔で別の場面を撮ったときに1つへ混ざります。
そこで2段目として使ったのが、perceptual hash、略して phash です。
これは画像の見た目の特徴を64bitの値に縮め、2つの値で異なるbitの数を距離として数える方法です。
完全一致だけを見るファイルのハッシュとは違い、少し構図が動いた写真同士でも距離の小ささとして似ている度合いを扱えます。

cli.py の _compute_phash() は、Immich から取得したサムネイルを Pillow で開き、RGBへ変換して imagehash.phash() に渡します。
元画像ではなく GET /assets/{id}/thumbnail?size=thumbnail の縮小画像を使っています。
phash は計算の最初に画像を小さく縮めてから特徴を取り出すため、サムネイルでも同じ場面かどうかの比較には足ります。
数千枚の元画像を毎回ダウンロードするより、転送量もずっと少なく済みます。

既定の PHASH_DISTANCE_THRESHOLD=10 は、64bitのうち異なるbitが10以内なら辺を結ぶ、という意味です。
その後、group_by_hash_distance() が Union-Find で連結成分を作ります。
Union-Find は、どの写真同士がつながっているかを効率よくまとめるためのデータ構造です。

class _UnionFind:
    def __init__(self, size: int) -> None:
        self._parent = list(range(size))

    def find(self, x: int) -> int:
        while self._parent[x] != x:
            self._parent[x] = self._parent[self._parent[x]]
            x = self._parent[x]
        return x
# ...(省略)
def group_by_hash_distance(
    assets: Sequence[AssetInfo], threshold: int
) -> list[list[AssetInfo]]:
    count = len(assets)
    if count == 0:
        return []

    uf = _UnionFind(count)
    for i in range(count):
        if assets[i].phash is None:
            continue
        for j in range(i + 1, count):
            if assets[j].phash is None:
                continue
            if phash_distance(assets[i].phash, assets[j].phash) <= threshold:
                uf.union(i, j)
    # ...(省略)

すべての写真が互いに距離10以内である必要はなく、AとB、BとCが近ければ、AとCが10を超えていても同じ連結成分になります。
これにより、連写中に構図が少しずつ動いても1組としてたどれます。

実行例では、12枚のグループの0.7秒後に別場面の4枚が続きました。
時間だけなら同じクラスタですが、phashを通すと最大距離6の12枚と最大距離2の4枚に分かれました。

詳細3:確認してから適用する

scan が作る proposals.json には、判定値、集計、各グループのアセットID、ファイル名、撮影時刻、phash、距離が入ります。
実際に保存された先頭アセットの記録は次のような形です。

{
  "id": "4a6fc05e-...",
  "file_name": "IMG_0002.jpg",
  "taken_at": "2026-05-10T01:05:12+00:00",
  "phash": "a3cd9d58e2277183"
}

まとめたくない候補があれば、apply の前に該当するグループを groups から削除できます。
scan の時点では書き込みがないので、しきい値を変えて候補を作り直しても大丈夫です。

既存スタックに所属する写真は asset.stack を見て除外し、過去に処理したアセットIDも state.json と照合して除外します。
サムネイル取得や phash 計算に失敗した写真は phash=None のまま単独になり、2枚以上という採用条件を満たさないため候補に入りません。

apply では、次の部分がスタック作成と中断再開を担当します。

        try:
            result = client.create_stack(asset_ids)
        except (ImmichApiError, ValueError) as exc:
            print(f"  失敗: {label} — {exc}", file=sys.stderr)
            failed += 1
            continue

        # ...(省略)
        state["stacks"].append(
            {
                "stack_id": stack_id,
                "primary_asset_id": primary,
                "asset_ids": asset_ids,
                "created_at": datetime.now(timezone.utc).isoformat(),
            }
        )
        processed_ids.update(asset_ids)
        state["processed_asset_ids"] = sorted(processed_ids)
        # 途中で中断されても再実行時に二重処理しないよう、都度保存する
        store.save_state(args.state, state)
        created += 1

client.create_stack(asset_ids) が呼ぶのは POST /stacks だけで、削除や既存スタックの変更を行う API は呼びません。
成功したグループは、その場で state.json に保存します。
最後にまとめて保存する作りではないため、途中で止まっても完了済みのIDが残り、再実行時の二重処理を避けられます。

どう作ったか

プロジェクトは、API通信、判定、設定、JSON保存、CLIを分けた構成です。

immich-burst-stacker/
├── immich_burst_stacker/
│   ├── api.py          137行  Immich API、時刻の取り出し
│   ├── cli.py          339行  scan / apply とレポート表示
│   ├── clustering.py   162行  時間クラスタと phash の判定
│   ├── config.py        72行  .env の読み込みと検証
│   └── store.py         49行  proposals.json / state.json の保存
├── tests/
│   ├── test_clustering.py
│   └── test_api_parsing.py
└── requirements.txt

データは、ImmichClient.iter_image_assets() → _collect_candidate_assets() → build_time_clusters() → _compute_phash() → burst_groups_in_time_cluster() → _group_to_dict() の順に流れます。
apply 側は store.load_json() で候補を読み、ImmichClient.create_stack() で作成し、store.save_state() で処理済みIDを残します。

clustering.py は Immich API に依存せず、テストでは imagehash.ImageHash の代わりに距離を返す FakeHash を使います。

使っている API は次の4系統です。

用途 メソッドとパス 主な値
疎通確認 GET /server/version 接続先のバージョンを表示
画像検索 POST /search/metadata type=IMAGE、withExif=true、withStacked=true、order=asc、size は最大1000
サムネイル GET /assets/{id}/thumbnail size=thumbnail
スタック作成 POST /stacks {"assetIds": [...]}、先頭が primary、最低2件

画像検索の応答は assets.items を読み、assets.nextPage が無くなるまでページを進めます。
withStacked=true にして既存スタックの写真も取得したうえで、呼び出し側が stack フィールドを見て確実に除外する作りです。

テストは test_clustering.py と test_api_parsing.py の2ファイル、合計24件です。
時刻のUTC化とフォールバック、2秒ちょうどの境界、入力順の並べ替え、phashの推移的な連結、ハッシュが無い写真の除外、離れた似た写真をまとめないことなどを確認しています。
2026年9月3日の実測では、pytestで24件すべてが0.16秒で通過しました。

導入と設定

動作環境は Python 3.x と Immich v2.5.3 です。
私の Immich は Docker で動かしており、API はポート 2283 を使っています。
Python側の実行時依存は requests、python-dotenv、Pillow、imagehash で、テストには pytest を使います。

同じ構成を用意する場合は、プロジェクトのフォルダで仮想環境を作り、依存関係を入れます。

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env

Immich の Web UI にログインし、右上のユーザーアイコンから「アカウント設定」→「API キー」と進み、新しいキーを発行します。
実際の権限エラーから確認できた必要権限は asset.read、asset.view、stack.create です。
発行したキーは画面に載せたり共有したりせず、.env の IMMICH_API_KEY に入れます。

IMMICH_API_URL=http://127.0.0.1:2283/api
IMMICH_API_KEY=your_api_key
TIME_WINDOW_SECONDS=2
PHASH_DISTANCE_THRESHOLD=10

IMMICH_API_URL の既定値は http://127.0.0.1:2283/api で、末尾の /api まで含めます。
TIME_WINDOW_SECONDS の既定値は 2、PHASH_DISTANCE_THRESHOLD の既定値は 10 です。
前者は小数も受け取る float、後者は整数の int として読み込み、不正な値なら設定エラーにします。

CLIには、.env の場所を変える --env-file、候補ファイルを変える --proposals、処理済み記録を変える --state もあります。
何も指定しなければ、カレントフォルダの .env、proposals.json、state.json を使います。

使い方

使い方は scan、確認、apply の順です。

  1. .venv/bin/python -m immich_burst_stacker scan で候補を検出します。
  2. 標準出力のレポートと proposals.json を確認し、まとめないグループがあればJSONから削除します。
  3. .venv/bin/python -m immich_burst_stacker apply で残した候補をスタック化します。

45枚を入れた日の scan から、集計と時間だけでは分けられなかったグループ2・3を抜き出すと次のとおりです。
表示内容は実際の出力そのままです。

Immich に接続しました: v2.5.3
アセットを取得しています...
時間クラスタ(2 枚以上): 4 件。サムネイルを取得して phash を計算します...

========================================================================
スタック候補レポート
========================================================================
取得アセット数            : 45
  既存スタックのため除外  : 0
  処理済みのため除外      : 0
  撮影時刻不明のため除外  : 0
判定対象                  : 45
時間クラスタ数(2枚以上)   : 4
設定                      : 時間窓 2.0 秒 / phash 距離しきい値 10
スタック候補グループ数    : 5

--- グループ 2 (12 枚, 最大ハッシュ距離 6) ---
   1. IMG_0011.jpg  2026-05-10 04:02:30.000 UTC  前との距離: - [primary]
   2. IMG_0012.jpg  2026-05-10 04:02:30.300 UTC  前との距離: 2
  12. IMG_0022.jpg  2026-05-10 04:02:33.300 UTC  前との距離: 2

--- グループ 3 (4 枚, 最大ハッシュ距離 2) ---
   1. IMG_0023.jpg  2026-05-10 04:02:34.000 UTC  前との距離: - [primary]
   2. IMG_0024.jpg  2026-05-10 04:02:34.400 UTC  前との距離: 2
   3. IMG_0025.jpg  2026-05-10 04:02:34.800 UTC  前との距離: 2
   4. IMG_0026.jpg  2026-05-10 04:02:35.200 UTC  前との距離: 2

時間クラスタは4件なのに、スタック候補は5件です。
グループ2と3が0.7秒差で続いており、時間では同じクラスタになったあと、phashで別の場面に分かれたためです。

内容を確認して apply を実行した結果は次のとおりです。
UUIDとファイルパスは読みやすいように短くしています。

5 件のグループをスタック化します。
  作成: グループ 1 (8 枚) stack_id=5b3bf1b8-... primary=4a6fc05e-...
  作成: グループ 2 (12 枚) stack_id=fed18d74-... primary=fcb3e418-...
  作成: グループ 3 (4 枚) stack_id=502b1970-... primary=4267ad8a-...
  作成: グループ 4 (6 枚) stack_id=03aa570c-... primary=34ac8e5b-...
  作成: グループ 5 (10 枚) stack_id=0d7f82cf-... primary=889ecbdd-...

作成: 5 件 / スキップ: 0 件 / 失敗: 0 件
処理済みアセットを state.json に記録しました(合計 40 件)

5件すべてが作成され、連写40枚分のIDが state.json に記録されました。

Immich のビューアで10枚のスタックを開き下部に写真のストリップが表示された画面

スタックを開くと、ビューア下部のストリップから10枚を切り替えて見られます。

実際に自分のライブラリで動かした結果

2026年8月10日に本番ライブラリで実行した結果は次のとおりです。

  • 取得した写真:22,431枚
  • 時間クラスタ:2,008件
  • スタック候補:2,351グループ
  • スタック化した写真:12,412枚

グループの枚数分布も確認しました。

1グループの枚数 グループ数
2枚 1,112
3枚 271
4枚 166
5枚 126
6枚 131
7枚 102
8枚 62
9枚 66
10枚 53

最大は71枚でした。
2枚のグループが1,112件と、全体の半数近くを占めています。

よくあるトラブルとQ&A

Q. 403 Forbidden で止まります

A. APIキーの権限を確認してください。
asset.read だけのキーで試したところ、POST /search/metadata は200でしたが、サムネイル取得は403の Missing required permission: asset.view、スタック作成は403の Missing required permission: stack.create になりました。
キーには asset.read、asset.view、stack.create が必要です。

Q. 撮影時刻が無い写真はどうなりますか

A. dateTimeOriginal と fileCreatedAt の両方を取得できない写真は、scan の候補から外れます。
レポートの「撮影時刻不明のため除外」で件数を確認できます。

Q. 連写なのに別グループになります

A. 隣り合う写真の間隔が TIME_WINDOW_SECONDS を超えると、別の時間クラスタになります。
2秒より間隔が空く撮り方なら、この値を少し広げて scan をやり直し、候補を確認してみてください。

Q. 似た別の場面まで同じグループになりました

A. phashは推移的に連結するため、間をつなぐ写真があると、端同士の距離がしきい値を超えていても同じグループになります。
まず PHASH_DISTANCE_THRESHOLD を小さくして再度 scan するか、その候補だけ proposals.json の groups から削除してから apply してください。

Q. 作ったスタックを解除したいです

A. このCLIには削除や解除の処理を入れていません。
作成後のスタックは Immich の UI で確認し、必要なものだけ画面から解除してください。

関連記事

Immichそのものの導入や、自宅サーバーで動かしている4コンテナの構成は、Google PhotosをImmichで自前に置き換えるにまとめています。
この記事は、その環境にAPI経由の連写整理を足す内容です。

まとめ

いかがでしたか。
今回は、Immichの連写写真を撮影時刻とphashで見つけ、確認してからスタック化する自作CLIを紹介しました。

振り返ると、ポイントは次の4つです。

  1. build_time_clusters() が隣接2秒以内の写真を時間クラスタへまとめる
  2. _compute_phash() と group_by_hash_distance() が64bitのphash距離10以内を連結する
  3. scan は書き込まず、proposals.json を確認してから apply する
  4. state.json を1グループごとに保存し、中断後の二重処理を避ける

22,431枚を人の手だけで見直すのは面倒でしたが、時間で候補を狭め、見た目で分け、最後だけ人が確認する形なら扱いやすくなりました。
この記事が誰かの役に立てばうれしいです。

コメント

読み込んでいます…

この記事を書いた人

リットン

都内金融機関で経営企画とデータアナリストをしているアラサー。
文系の非エンジニアですが、独学で始めた Python と生成AIにすっかりはまりました。
プログラミング・サッカー・ミステリ紹介など、好きなことを好きに書いています。

プロフィールを見る →