Pythonでパノラマ生成だ！

# はじめに
いきなりですが、Pythonでパノラマ画像を作成してみます。

# 素材
今回は以下の4つの画像を合成して1枚の画像にします。

![4枚の画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/c27fbdd1-e910-401b-b0fe-ec73731f6b44.png)

# 結果
先に完成品をお見せします。
おお...、これはかなりいい感じで合成できますね。

![panorama.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/3382f149-8736-4350-a944-a1f9c360cbd3.jpeg)

# 環境
Windows11+Python3.12
CPUのみで合成を行います。

opencv-contrib-python 4.10.0.84
opencv-python 4.11.0.86
stitching 0.6.1

# ライブラリ
CV2のStitcher_createなどが有名ですが、パラメータの解説が少ないのと、自由度が少ないので、今回はcv2をラッパーしたようなライブラリstitchingを使用します。

stitchingは日本語の解説記事がほとんど無いのですが、実装の中身がわかりやすく、パラメータ自由度も高いです。

https://github.com/OpenStitching/stitching


# 実行

### インストール
```
pip install stitching
```

### コード
まずは公式の指定通りに実行してみます。

```python
import sys
import glob
import os
import cv2
from stitching import Stitcher

# ──────────────────────────────────────────
# 設定値
FOLDER = r"img"  # 画像フォルダ
SAVE_IMAGE_NAME = r"panorama.jpg"  # 出力ファイル名

STITCH_SETTINGS = {
    "detector":     "sift",
    "confidence_threshold": 0.2,
}


if __name__ == "__main__":
    # 1. フォルダ内の画像を読み込む
    paths = sorted(glob.glob(os.path.join(FOLDER, "*.jpg"))
                   + glob.glob(os.path.join(FOLDER, "*.png")))
    images = [cv2.imread(p) for p in paths if cv2.imread(p) is not None]

    # 3. Stitcher で合成
    try:
        stitcher = Stitcher(**STITCH_SETTINGS)
        panorama = stitcher.stitch(images)
    except Exception as e:
        sys.exit(f"Stitch 失敗: {e}")

    # 4. 保存
    if cv2.imwrite(str(SAVE_IMAGE_NAME), panorama):
        print(f"保存完了 → {SAVE_IMAGE_NAME}")
    else:
        sys.exit("ERROR: 保存に失敗しました")

```

うまく合成されましたが、少し山頂部分が見切れていますね。
![合成後の画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/dddad772-b5e3-4e65-83a9-81389ca94269.jpeg)

# 分析
とりあえず分析してみましょう。
`stitcher.stitch_verbose`を使うことで、合成途中の経過をログ出力してくれます。
`VERBOSE_DIR`でフォルダ指定してください。なお、先にフォルダを作成しておかないとエラーで止まります。

```diff_python
- panorama = stitcher.stitch(images)
+ panorama = stitcher.stitch_verbose(images, verbose_dir=VERBOSE_DIR)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/e6b4f44e-555f-46f7-bc63-9b0fe95f1d26.png)

それらしいファイルが表示されました。
ポイントは以下の2枚ですね。途中の黒帯部をクロップする過程で山頂部が消えてしまったようですね。
黄・緑帯の画像が悪さをしていそうです。

![06_lir.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/31a08139-6bb5-4402-b8d0-c1b5e3995496.jpeg)

![09_result_with_seam_polygons.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/44dfec2a-bdf5-4d4f-916c-feba3516c344.jpeg)

# ステップアップの改善
いくつかの改善パターンを紹介します。

## detector変更

とりあえず、特徴検出器(detector)を`sift`→`orb`に変更してみます。
```diff_python
STITCH_SETTINGS = {
-   "detector":     "sift",
+   "detector":     "orb",
    "confidence_threshold": 0.4,
}
```
実行してみましたが、エラーが出ました。
```console
Stitch 失敗: Camera parameters adjusting failed.
```

うまくカメラ位置が認識できていませんね。
このような場合は、`verbose_dir`で指定したフォルダを確認してみましょう。
`03_matches_graph.txt`を開いてみてください。

ダメな例：
```text:03_matches_graph.txt
graph matches_graph{
"1" -- "2"[label="Nm=163, Ni=99, C=1.73989"];
"1" -- "3"[label="Nm=28, Ni=8, C=0.487805"];
"1" -- "4"[label="Nm=19, Ni=5, C=0.364964"];
}
```

正常な例：
```text:03_matches_graph.txt
graph matches_graph{
"1" -- "2"[label="Nm=1869, Ni=1144, C=2.01161"];
"2" -- "3"[label="Nm=605, Ni=235, C=1.24011"];
"3" -- "4"[label="Nm=460, Ni=200, C=1.36986"];
}
```
通常、うまく認識した場合、
1→2
2→3
3→4
のように画像のつながりが認識されます。

ダメな例では、
1→2
1→3
1→4
のように、すべての画像が1枚目の画像と関連づけられています。
これではパノラマになりませんね。

## match_conf 変更

ということで、特徴の関連付けがうまくできていないと仮定して、設定を変えてみます。
match_conf（マッチ信頼度閾値）を0.15に設定してみます。
orbの場合はデフォルトで0.3です。

```diff_python
STITCH_SETTINGS = {
    "detector":     "orb",
    "confidence_threshold": 0.4,
+   "match_conf":   0.15,
}
```

```text:03_matches_graph.txt
graph matches_graph{
"1" -- "2"[label="Nm=323, Ni=174, C=1.65872"];
"2" -- "3"[label="Nm=156, Ni=38, C=0.693431"];
"3" -- "4"[label="Nm=179, Ni=84, C=1.36143"];
}
```

やりました！うまく合成できました。
しかし合成部の色味(空の色）の変化が気になりますね。

![panorama.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/f9fd8c17-ade9-44b6-961d-4e8be0567141.jpeg)


## ブレンド変更
ブレンドの設定を変更してみます。
ひとまず以下の設定を追加してみました。

```diff_python
STITCH_SETTINGS = {
    "detector":     "orb",
    "confidence_threshold": 0.4,
    "match_conf":   0.15,
+   "finder": "gc_colorgrad", # シーム(重なり境界)検出器
+   "blender_type": "multiband",# ブレンダー方式
+   "blend_strength": 40, # ブレンド幅係数。大きいほど滑らか＆ぼやける
}

```

いいですね！かなり自然な感じになりました。

![panorama.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173513/a61cfa36-da62-427a-8417-d345565e318e.jpeg)




# 設定できるパラメータ一覧

デフォルト値は★でマーキングしています。
ドキュメントが少なく、調べた限りの内容ですので、解説に間違いもあるかもしれません。

## 選択系のパラメータ

### detector
特徴点検出アルゴリズムの選択  

| 値 | 特徴 |
|----|------|
| ★ `orb` | 高速・軽量。回転・照度変化に強いが、スケール変化にはやや弱い。`nfeatures` が有効。 |
| `sift` | スケール・回転・照度変化に強いが、やや遅い。`nfeatures` が有効。  |
| `brisk` | 回転・スケール両対応の高速処理。|
| `akaze` | 速度と耐性のバランス型。 |

※ sift,brisk,akazeも割と良い。verbose_dir内の画像の緑点を確認して、適切なものを選択。

---

### matcher_type
特徴matcherの種類 (射影／アフィン)  

| 値 | 特徴 |
|----|------|
| ★ `homography` | 通常のパノラマ。オーバーヘッド少なく高速。 |
| `affine` | アフィン変換。平面シーンやドローン俯瞰など傾きが小さい場合に有利。 |

---

### estimator
カメラパラメータ初期推定器  

| 値 | 特徴 |
|----|------|
| ★ `homography` | 一般的パノラマ用。遠方風景や旋回撮影向け。 |
| `affine` | シーン深度が浅い・平面基準のとき安定（例: スキャナ画像合成）。 |

---

### adjuster
バンドル調整器の種類  

| 値 | 特徴 |
|----|------|
| ★ `ray` | 球面射影に基づくレイ最小化。汎用的で収束性良好。 |
| `reproj` | 再投影誤差を直接最小化。精度は高いが失敗しやすい。<br>もう、ほんと失敗しやすい...。<br>ズレもしっかりマッチするが、アスペクト比が狂いやすい。|
| `affine` | アフィンモデル専用。`estimator=affine` と組み合わせ必須。 |
| `no` | 調整を行わない。高速だがブレンドずれが残りやすい。 |

---

### wave_correct_kind
回転行列のウェーブ補正方向  

| 値 | 特徴 |
|----|------|
| ★ `horiz` | カメラを横方向に振った普通のパノラマ。水平線が直線になる。 |
| `vert` | 縦方向に振った場合（上下パノラマ）。 |
| `auto` | 自動判別。複雑な並びでも失敗少なめ。 |
| `no` | 補正なし。建物が歪む／波打つことがあるが計算は速い。 |

※カメラの振った向きがわかっていれば、事前指定が吉

---

### warper_type
画像の射影モデル  

| 値 | 特徴 |
|----|------|
| ★ `spherical` | 最も一般的。全周囲撮影に強いが両端が圧縮される。 |
| `cylindrical` | 地平線が水平に保たれ、上下の歪みが小さい。屋外風景で良い。 |
| `plane` | 透視射影。視野が狭い／ブレンド数が少ないときに自然。 |
| `mercator` | メルカトル。引き伸ばされるので、ゆがみが大きい |
| `affine` | アフィン前提パノラマ。`estimator=affine` とペアで使用。 |

※ほかにも以下あり。
fisheye,
transverseMercator,
stereographic,
compressedPlaneA2B1,
compressedPlaneA1.5B1,
compressedPlanePortraitA2B1,
compressedPlanePortraitA1.5B1,
paniniA2B1,
paniniA1.5B1,
paniniPortraitA2B1,
paniniPortraitA1.5B1

---

### compensator
露光誤差補正方式  

| 値 | 特徴 |
|----|------|
| ★ `gain_blocks` | ブロック単位で輝度ゲインを調整。周辺光量落ちに強い。 |
| `gain` | 画像全体で 1 値のゲインを推定。高速だがムラに弱い。 |
| `channel` | RGB 各チャネル毎にゲイン補正。色被りも補正。 |
| `channel_blocks` | `channel`+`blocks` のハイブリッド。高精度だが重い。 |
| `no` | 補正無効。照度差が小さい場合や処理を早めたい場合。 |

---

### finder
シーム(重なり境界)検出器  

| 値 | 特徴 |
|----|------|
| ★ `dp_color` | Dynamic-Programming (DP) + 色差。高速で滑らかなシーム。 |
| `dp_colorgrad` | 色 + 勾配差を併用。テクスチャが複雑なとき縫い目が目立ちにくい。 |
| `gc_color` | Graph-Cut + 色差。計算コストup、テクスチャ変化大きくても比較的良好 |
| `gc_colorgrad` | Graph-Cut + 色+勾配。品質は良いが遅い。 |
| `voronoi` | 単純分割。高速だがシームが直線的で目立ちやすい。 |
| `no` | シーム検出なし (マスク重ね合わせのみ)。高速 |

---

### blender_type
ブレンダー方式  

| 値 | 特徴 |
|----|------|
| ★ `multiband` | ピラミッド階層ブレンド。照度差・パララックスに強い。 |
| `feather` | ガウスぼかしの重み付け。高速だがゴーストが出やすい。 |
| `no` | マスクで単純合成。超高速だが境界がそのまま残る。 |

---

### timelapse
タイムラプスモード  

| 値 | 特徴 |
|----|------|
| ★`no` | 通常のパノラマ生成。 |
| `as_is` | 各フレームを歪ませずにパノラマ座標へ配置して保存。 |
| `crop` | `as_is` と同じだが不要領域を自動クロップして保存。 |

---



## 数値系パラメータ


### nfeatures  
detectorで`orb`および`sift` 選択時のみ有効。
最大キーポイント数（特徴ポイント数）
verbose_dir内の画像の緑点のこと。

| 設定値 | 説明 |
|--------|------|
| ★ `500` | 整数で指定。大きいほど特徴点が増えマッチ精度が上がるが処理時間も上がる<br>特徴点が多すぎても、変なマッチをすることがある。 |

---

### range_width  
マッチ対象の隣接画像を窓で制限  

| 設定値 | 説明 |
|--------|------|
| ★ `-1` | すべての画像ペアをマッチ |
| `1` 以上の整数 | その指定枚数±n枚だけ隣接画像（フレーム）だけをマッチ。<br>少ない方が高速<br>渡す画像の順番が正しければ、異常なマッチを低減できる|

---

### match_conf  
画像ペア同士の特徴マッチの閾値  

| 設定値 | 説明 |
|--------|------|
| ★ `None` | アルゴリズムで自動選択（ORB: 0.3, それ以外: 0.65） |
| `0.0`–`1.0` | 小さくすると低品質マッチも採用し画像が多く残る |

---

### confidence_threshold  
マッチ後の画像ペアから採用するか否かを決める閾値。

| 設定値 | 説明 |
|--------|------|
| ★ `1.0` | 0–1.0。値を下げると弱いリンクも許容し枚数を優先 |

※ match_confでペア推定 → confidence_thresholdはそのペアの信頼度の閾値。

---


### refinement_mask  
調整する内部パラメータ 
焦点距離・skew・aspectなどの調整する5個のパラメータ。xで有効
1個ずつ無効化して効果を確認。

| 設定値 | 説明 |
|--------|------|
| ★ `xxxxx` | デフォルトはすべて有効。設定例：x0x0x |

---


### medium_megapix
特徴検出・マッチング用に画像を中解像度へ縮小するときの総画素数 (メガピクセル)

| 設定値 | 説明 |
|--------|------|
| ★ `0.6` | -1 でリサイズ無し  <br>小さくすると速度up精度down |
---

### low_megapix  
マスク・露光補正・シーム検出用の低解像度の総画素数 (メガピクセル)

| 設定値 | 説明 |
|--------|------|
| ★ `0.1` | 小さくすると速度up精度down |

---

### final_megapix  
最終出力の総画素数 (メガピクセル) 

| 設定値 | 説明 |
|--------|------|
| ★ `-1` | リサイズせずフル解像度で出力 |
| 整数 | 指定に合わせてリサイズ。小さくすると速度up精度down |

---

### blend_strength  
ブレンダーのブレンド幅係数  

| 設定値 | 説明 |
|--------|------|
| ★ `5` | 0–100。大きいほど継ぎ目は滑らか・ぼやける。<br>20ぐらいでもいい感じ。|

---

### nr_feeds  
露光誤差補正方式 (compensator)で、`channel`および"channel_block"を指定したときに有効。
更新パス回数。ムラが残るとき増やす。

| 設定値 | 説明 |
|--------|------|
| ★ `1` | 正の整数。2 以上にするとムラが安定するがその分遅くなる |

---

### block_size  
同じく露光誤差補正方式 (compensator)で`channel_blocks`を選択したときの補正器のブロック辺長 (px)  

| 設定値 | 説明 |
|--------|------|
| ★ `32` | 大きいほど補正は滑らか，小さいと局所適応力が良くなる |

---

### crop
自動クロップ設定  

| 値 | 特徴 |
|----|------|
| ★ `True` | 合成後、Largest Interior Rectangle アルゴリズムで黒帯を除去。 |
| `False` | オリジナル範囲をそのまま出力。 |

---

### try_use_gpu  
CUDA の選択 

| 設定値 | 説明 |
|--------|------|
| ★ `False` | GPU を使わない |
| `True` | GPUが使えればシーム検出・ブレンドが高速化 |



# 設定例（フルコード）

```python
from pathlib import Path
import sys
import glob
import os
import cv2
from stitching import Stitcher

# ──────────────────────────────────────────
# 設定値
FOLDER = r"img"  # 画像フォルダ
SAVE_IMAGE_NAME = r"panorama.jpg"  # 出力ファイル名
VERBOSE_DIR = r"debug" # 途中経過のログ出力。事前にフォルダ作成

STITCH_SETTINGS = {
    # 特徴点検出・マッチング
    "detector":     "orb",
    "nfeatures":    2000,
    "matcher_type": "homography",
    "range_width":  2,
    "match_conf":   0.15,
    # 画像選別
    "confidence_threshold": 0.4,  # 指定値
    # カメラ推定・調整
    "estimator":       "homography",
    "adjuster":        "ray",
    "refinement_mask": "x0x0x",
    "wave_correct_kind": "horiz",
    # ワーピング
    "warper_type": "cylindrical",
    # 露光補正
    "compensator": "gain_blocks",
    "nr_feeds":    2,
    "block_size":  32,
    # シーム検出
    "finder": "gc_colorgrad",
    # ブレンディング
    "blender_type":   "multiband",
    "blend_strength": 20,
    # タイムラプス
    "timelapse":        "no",
    "timelapse_prefix": "fixed_",
    # そのほか
    "crop": True,
    "try_use_gpu": False,
}


if __name__ == "__main__":
    # 1. フォルダ内の画像を読み込む（拡張子は適当に変更可）
    paths = sorted(glob.glob(os.path.join(FOLDER, "*.jpg"))
                   + glob.glob(os.path.join(FOLDER, "*.png")))
    images = [cv2.imread(p) for p in paths if cv2.imread(p) is not None]

    # 3. Stitcher で合成
    try:
        """Stitcher でパノラマ作成"""
        stitcher = Stitcher(**STITCH_SETTINGS)
        panorama = stitcher.stitch_verbose(images, verbose_dir=VERBOSE_DIR)
    except Exception as e:
        sys.exit(f"Stitch 失敗: {e}")

    # 4. 保存
    if cv2.imwrite(str(SAVE_IMAGE_NAME), panorama):
        print(f"保存完了 → {SAVE_IMAGE_NAME}")
    else:
        sys.exit("ERROR: 保存に失敗しました")
```
# Tips
iPhoneなどのパノラマ撮影は、スマホ内の加速度センサなどを組み合わせ、カメラ位置・角度を推定しながら、高精度に合成しています。
しかし、今回の方法では画像内の特徴量だけを頼りにパノラマ化を行うので、背景に特徴がなかったり、同じような構図が続く場合はうまく合成が上手くいきません。

* 特徴的な物体を配置して撮影するなど、撮影時点から工夫
* オーバーラップを十分に取る（30%〜50%程度）
* カメラの視点（レンズの中心）を動かさず、その場で回転するように撮影する（視差を減らす）

といったテクニックが有効です。

こちらの記事が非常に参考になりました。

https://qiita.com/itoshogo3/items/7a3279668b24008a3761

# おわりに
stitchingを使ったパノラマ画像の生成を解説しました。

パノラマ化の専用ソフトは多々あれど、画像検査の工程など、パノラマ化を自動工程に組み込みたいケースも多々あると思います。
そんなときにpython+stitchingで自動化してみましょう！

難しさもありますが、うまく合成できると非常に面白いので、みなさんもぜひ試してみてください。

次回記事では動画からパノラマ化までをトライしてみようと思います。