📸

Metashapeで全天球画像をキューブマップに変換

に公開
1

Metashapeは全天球画像(エクイレクタングラー)の処理に対応していますが、アライメント結果を他のSfM系ソフトで利用する場合は、全天球画像のままでは渡すことが出来ません。
postshot、RealityCapture、Inria 3D Gaussian Splatting等は全天球画像には対応していないためです。

しかし先日smert999氏が「Agisoft_metashape_convert_to_cubemap」を公開されました!

Metashapeでアライメントした全天球画像を、そのアライメント結果を維持したままキューブマップに変換できるというものです。

Theta, Insta360等で360度動画/全天球画像撮影 → Metashapeでアライメント → キューブマップ変換 → RealityCaptureで活用・postshotで3DGS生成
といったワークフローが可能に!

これが、

こうなって、 (キューブマップ変換された)

こうして、 (RealityCaptureにも読み込める)

こう、みたいな。 (postshotで3DGS)

そんな事ができちゃいます。

使い方は少し躓くところもあったので、以下に手順をまとめてみました。

前提条件

Metashapeでスクリプトを走らせる必要があるので、Metashape Pro版が必要です。

手順

リポジトリにアクセス。
https://github.com/smert999/Agisoft_metashape_convert_to_cubemap

クローンまたはzipダウンロードする。

使うのは「convert_to_cubemap_v009.py」のみです。

Metashape Proを立ち上げ、メニューのツールから「スクリプトを実行」を選択。

ダウンロードした「convert_to_cubemap_v009.py」を指定し、OKをクリック。

次のエラーが出た場合は、OpenCVが足りないのでインストールします。

OpenCVはMetashape専用Pythonにインストールするので、以下のコマンドを実行します。

"C:\Program Files\Agisoft\Metashape Pro\python\python.exe" -m pip install opencv-python

Metashape Proを再起動し、再度スクリプトを実行します。

すると変換後のデータを保存する場所を聞かれるので任意のフォルダを指定します。

ここからいくつかダイアログが表示されますが、ロシア語なので翻訳も添えました。

「オーバーラップの値(0-20)を入力してください」

「立方体の面のサイズ」

デフォルトはオート設定。

「ファイル形式(フォーマット)を選択してください (jpg, png, tiff)」

そして、エラー。
「エラー:処理中にエラーが発生しました:関数は最大2つの引数しか受け付けませんが、4つ与えられました (function takes at most 2 arguments (4 given))」

設定を誤ってしまったようですが、このスクリプトはGUIモードでも動かせるので、そちらの方が各種設定がわかりやすいのでそっちを使ってみます。

GUIモードで使うには「PyQt5」が必要なのでこれを入れます。

コマンドプロンプトを立ち上げて以下のコマンドを実行。

"C:\Program Files\Agisoft\Metashape Pro\python\python.exe" -m pip install pyqt5

インストールできたらMetashape Proを再起動し、
スクリプトを再実行します。

GUIで立ち上がりました。

表示されているダイアログの内容は
「インターフェースをノンブロッキングモード(非ブロックモード)で起動しますか?」です。

各設定項目の日本語訳は以下のとおりです。

ウィンドウタイトル

球面画像をキューブ投影に変換

設定 (Настройки)
  • 出力フォルダ (Выходная папка): 未選択 (Не выбрана)
    • [参照...] (Обзор...) ボタン
    • オーバーラップ (度) (Перекрытие (градусы)): 10.0
  • キューブ面のサイズ (Размер грани куба): 自動 (推奨) (Автоматически (рекомендуется))
  • 座標系 (Координатная система): 自動検出 (Автоопределение)
  • スレッド数 (Количество потоков): 6
画像パラメータ (Параметры изображения)
  • ファイル形式 (Формат файла): JPEG (.JPG)
  • 品質 (Качество): 95
  • 補間 (Интерполяция): キュービック法(低速、高品質)(Кубическая (медленнее, лучше качество))
    • Ближайшая (быстрее, хуже качество):ニアレストネイバー法 (高速、低品質)
    • Линейная (средняя)」:バイリニア法 (中間)
    • Кубическая (медленнее, лучше качество):バイキュービック法 (低速、高品質)
プロジェクト情報 (Информация о проекте)
  • 検出されたカメラ数 (Количество найденных камер): 10
  • 検出された座標系 (Определённая координатная система): Y_UP
処理の進捗 (Прогресс обработки)
  • 現在のカメラ (Текущая камера): なし (Нет)
    • (進捗バー)
  • ステータス (Статус): 開始待機中 (Ожидание запуска)
  • 残り時間 (Оставшееся время): ------
下部ボタン:
  • 開始 (Запустить)
  • 停止 (Остановить)
  • 閉じる (Закрыть)
左下ステータス:

準備完了 (Готов к работе)

各設定を終えたら、開始を押して実行します。

実行前

実行後

全天球画像の前後左右上下に生成された画像が配置されました!

スクリプトの調整

使い方は以上ですが、実は生成後のカメラが左右逆転してたり、UIがロシア語でわかりづらいところもあったので、スクリプトを調整してみました。

ここでは調整結果だけでなく、調整の過程も参考までに記してみます。
なお私は開発者ではなく、ふんわりなんとなくコードを読むことはあっても自ら書いたり変更したりは得意でないので、LLMでやってみました。

日本語化

今回はGeminiを利用しました。
プロンプトはこんな形。

1行で指示して、後半はスクリプト全文を丸ごとコピペです。

出来上がったスクリプトを実行するとー

GUIもちゃんと日本語されました。
わかりやすい!

不具合の調整

このスクリプトですが、Metashape上では一見正しく処理されている用に見えて、実は生成後のカメラが左右反転しているという状況になっていました。
このままCOLMAP形式に書き出してRealityCaptureやpostshotに持っていくと、正しくカメラが再現されませんでした。

おかしな状態になってしまった

そこでこれについてもGeminiに調整をお願いしてみました。
プロンプトはこんな感じ。

だいぶゆるいお願いの仕方ですが、これでもちゃんと正しい状態に修正されました。

修正版のスクリプトを走らせ、COLMAP形式に書き出し、これをRealityCaptureで読み込むと、正しい状態で読み込むことができました。

あとはお好きに料理。

RealityCapture等で初期点群も生成しておけば、全天球画像には対応していないpostshotで3DGS(ガウシアンスプラッティング)を生成してみたりも出来ちゃいます。

調整版のスクリプトは以下です。
(LLMで調整したものなのでおかしなところはあるかもしれない)
元のリポジトリもまだ公開されて間もなくつい昨日も更新されていたの、本家の方で修正はされるかもなので、Issueやプルリプ送るのも良いかも。

convert_to_cubemap_v009_jp.py

# === パート1: インポートと補助関数 ===
import os
import sys
import time
import traceback
import Metashape
import cv2
import numpy as np
import subprocess
import concurrent.futures

# GUIウィンドウを保持するグローバル変数
gui_window = None

# === 依存関係のチェックとインストール ===
def check_and_install_packages():
    """必要なライブラリが存在するかチェックし、インストールします。"""
    required_packages = {
        "PyQt5": "pyqt5"
    }

    missing_packages = []
    for module_name, pip_name in required_packages.items():
        try:
            __import__(module_name)
            print(f"ライブラリ {module_name} は既にインストールされています。")
        except ImportError:
            missing_packages.append(pip_name)

    if missing_packages:
        print(f"必要なライブラリがありません: {', '.join(missing_packages)}")
        try:
            metashape_python = os.path.join(os.path.dirname(Metashape.app.applicationPath), "python", "python.exe")
            for package in missing_packages:
                print(f"{package} をインストール中...")
                subprocess.check_call([metashape_python, "-m", "pip", "install", package])
            print("必要なライブラリはすべて正常にインストールされました。")

            # インストール後にモジュールを再読み込み
            for module_name in required_packages.keys():
                if module_name in sys.modules:
                    del sys.modules[module_name]

            return True
        except Exception as e:
            print(f"ライブラリのインストールに失敗しました: {str(e)}")
            print("スクリプトはコンソールモードで続行します。")
            return False

    return True

# PyQt5のインストールとインポートを試みる
use_gui = check_and_install_packages()

# インストールが成功した場合、PyQt5をインポートする
if use_gui:
    try:
        from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                                     QLabel, QPushButton, QProgressBar, QFileDialog,
                                     QMessageBox, QComboBox, QSpinBox, QDoubleSpinBox, QGroupBox)
        from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
        print("PyQt5が正常にインポートされました。グラフィカルインターフェースを使用します。")
    except ImportError as e:
        print(f"PyQt5のインポートエラー: {str(e)}")
        print("スクリプトはコンソールモードで続行します。")
        use_gui = False

# === 補助関数 ===
def console_progress_bar(iteration, total, prefix='', suffix='', length=50, fill='█', print_end="\r"):
    """
    コンソールにテキストベースのプログレスバーを作成します。
    """
    percent = ("{0:.1f}").format(100 * (iteration / float(total)))
    filled_length = int(length * iteration // total)
    bar = fill * filled_length + '░' * (length - filled_length)
    print(f'\r{prefix} |{bar}| {percent}% {suffix}', end=print_end)
    if iteration == total:
        print()

def show_message(title, message):
    """
    Metashape APIを使用してメッセージを表示します。
    APIのバージョンに応じて呼び出しを調整します。
    """
    try:
        # 2つの引数で呼び出してみる(新しいAPIバージョン)
        Metashape.app.messageBox(title, message)
    except TypeError:
        # それが機能しない場合、1つの引数で試してみる(古いAPIバージョン)
        Metashape.app.messageBox(f"{title}\n\n{message}")
    except Exception as e:
        # それでも機能しない場合は、コンソールにメッセージを出力するだけ
        print(f"\n{title}\n{'-' * len(title)}\n{message}")

def get_string_option(prompt, options):
    """
    オプションのリストから文字列の選択をユーザーに要求します。
    Metashapeの異なるAPIバージョンに対応しています。
    """
    try:
        # getIntを使用してリストから選択する試み
        print(f"{prompt} (選択肢: {', '.join(options)})")
        for i, option in enumerate(options):
            print(f"{i+1}. {option}")
        index = Metashape.app.getInt(f"{prompt} (1-{len(options)})", 1, 1, len(options))
        return options[index - 1]
    except:
        # getIntが機能しない場合、getStringを試す
        try:
            # まず新しいAPIバージョンを試す
            return Metashape.app.getString(prompt, options[0])
        except:
            # それが機能しない場合、単純なgetStringを使用し、入力を検証する
            result = Metashape.app.getString(prompt, options[0])
            if result in options:
                return result
            return options[0]  # 入力が不正な場合はデフォルト値を返す

def format_time(seconds):
    """時間を読みやすい形式にフォーマットします。"""
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    seconds = int(seconds % 60)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}"

# === パート2: 投影変換関数 ===
def eqruirect2persp_map(img_shape, FOV, THETA, PHI, Hd, Wd, overlap=10):
    """
    正距円筒図法から透視投影への変換のためのマッピングマップを作成します。
    """
    equ_h, equ_w = img_shape
    equ_cx = (equ_w) / 2.0
    equ_cy = (equ_h) / 2.0

    wFOV = FOV + overlap
    hFOV = float(Hd) / Wd * wFOV

    c_x = (Wd) / 2.0
    c_y = (Hd) / 2.0

    w_len = 2 * np.tan(np.radians(wFOV / 2.0))
    w_interval = w_len / (Wd)

    h_len = 2 * np.tan(np.radians(hFOV / 2.0))
    h_interval = h_len / (Hd)

    x_map = np.zeros([Hd, Wd], np.float32) + 1
    y_map = np.tile((np.arange(0, Wd) - c_x) * w_interval, [Hd, 1])
    z_map = -np.tile((np.arange(0, Hd) - c_y) * h_interval, [Wd, 1]).T
    D = np.sqrt(x_map ** 2 + y_map ** 2 + z_map ** 2)

    xyz = np.zeros([Hd, Wd, 3], np.float64)
    xyz[:, :, 0] = (x_map / D)[:, :]
    xyz[:, :, 1] = (y_map / D)[:, :]
    xyz[:, :, 2] = (z_map / D)[:, :]

    y_axis = np.array([0.0, 1.0, 0.0], np.float32)
    z_axis = np.array([0.0, 0.0, 1.0], np.float32)
    [R1, _] = cv2.Rodrigues(z_axis * np.radians(THETA))
    [R2, _] = cv2.Rodrigues(np.dot(R1, y_axis) * np.radians(-PHI))

    xyz = xyz.reshape([Hd * Wd, 3]).T
    xyz = np.dot(R1, xyz)
    xyz = np.dot(R2, xyz).T
    lat = np.arcsin(xyz[:, 2] / 1)
    lon = np.zeros([Hd * Wd], np.float64)
    theta = np.arctan(xyz[:, 1] / xyz[:, 0])
    idx1 = xyz[:, 0] > 0
    idx2 = xyz[:, 1] > 0

    idx3 = ((1 - idx1) * idx2).astype(bool)
    idx4 = ((1 - idx1) * (1 - idx2)).astype(bool)

    lon[idx1] = theta[idx1]
    lon[idx3] = theta[idx3] + np.pi
    lon[idx4] = theta[idx4] - np.pi

    lon = lon.reshape([Hd, Wd]) / np.pi * 180
    lat = -lat.reshape([Hd, Wd]) / np.pi * 180
    lon = lon / 180 * equ_cx + equ_cx
    lat = lat / 90 * equ_cy + equ_cy

    return lon.astype(np.float32), lat.astype(np.float32)

def determine_coordinate_system():
    """
    カメラの位置分析に基づいて座標系のタイプを決定します。
    """
    doc = Metashape.app.document
    chunk = doc.chunk
    cameras = [cam for cam in chunk.cameras if cam.transform]

    if not cameras:
        return "Y_UP"  # デフォルト

    orientation_votes = {"Y_UP": 0, "Z_UP": 0, "X_UP": 0}

    for camera in cameras[:5]:
        rotation = camera.transform.rotation()
        up_vector = rotation * Metashape.Vector([0, 1, 0])

        y_alignment = abs(up_vector.y)
        z_alignment = abs(up_vector.z)
        x_alignment = abs(up_vector.x)

        if y_alignment > z_alignment and y_alignment > x_alignment:
            orientation_votes["Y_UP"] += 1
        elif z_alignment > y_alignment and z_alignment > x_alignment:
            orientation_votes["Z_UP"] += 1
        elif x_alignment > y_alignment and x_alignment > z_alignment:
            orientation_votes["X_UP"] += 1

    determined_orientation = max(orientation_votes, key=orientation_votes.get)
    return determined_orientation

def fix_back_face_artifact(image):
    """
    画像中央の黒い縦縞アーティファクトを修正します。

    Parameters:
    -----------
    image : numpy.ndarray
        アーティファクトのあるキューブ面の元画像

    Returns:
    --------
    numpy.ndarray
        修正された画像
    """
    height, width = image.shape[:2]
    center_x = width // 2

    # 処理する帯の幅
    strip_width = 3

    # 処理用に画像のコピーを作成
    fixed_image = image.copy()

    # 処理するピクセルの範囲を定義
    left_x = max(0, center_x - strip_width)
    right_x = min(width - 1, center_x + strip_width)

    # 画像の各行について
    for y in range(height):
        # 中央の帯に黒いピクセルがあるか確認
        strip = image[y, left_x:right_x+1]

        # 暗すぎるピクセルがある場合(アーティファクトの可能性)
        if np.any(np.mean(strip, axis=1) < 30):  # しきい値は調整が必要な場合があります
            # 補間のために帯の左右の値を使用
            left_value = image[y, left_x-1] if left_x > 0 else image[y, right_x+1]
            right_value = image[y, right_x+1] if right_x < width-1 else image[y, left_x-1]

            # 左と右の値の間で線形補間
            for i, x in enumerate(range(left_x, right_x+1)):
                alpha = i / (right_x - left_x + 1)
                fixed_image[y, x] = (1 - alpha) * left_value + alpha * right_value

    # より良いブレンドのために修正された帯にのみスムージングを適用
    temp_mask = np.zeros_like(image)
    temp_mask[:, left_x:right_x+1] = 255

    # 修正された領域にガウスぼかしを適用
    blur_region = cv2.GaussianBlur(fixed_image, (5, 5), 0)

    # 元の画像とぼかした領域を結合するためにマスクを使用
    mask = temp_mask.astype(np.float32) / 255.0
    blended = (mask * blur_region + (1.0 - mask) * fixed_image).astype(np.uint8)

    return blended

# === パート3: マルチスレッドによる最適化された変換関数 ===
def convert_spherical_to_cubemap(spherical_image_path, output_folder, camera_label, persp_size=None, overlap=10,
                                 file_format="jpg", quality=95, interpolation=cv2.INTER_CUBIC, max_workers=None):
    """
    球面画像をキューブマップ投影に変換します。
    処理を高速化するためにマルチスレッドを使用します。

    Parameters:
    -----------
    spherical_image_path : str
        球面画像へのパス
    output_folder : str
        出力画像を保存するフォルダ
    camera_label : str
        カメララベル
    persp_size : int, optional
        キューブ面のサイズ。Noneの場合、自動計算されます
    overlap : float
        面間のオーバーラップ(度)
    file_format : str
        ファイル形式: "jpg", "png", "tiff"
    quality : int
        画像品質 (JPEG用): 1-100
    interpolation : int
        リマッピング時の補間方法
    max_workers : int, optional
        最大ワーカースレッド数。デフォルトは6とCPUコア数の最小値。

    Returns:
    --------
    dict
        作成されたキューブ面へのパスを含む辞書
    """
    if max_workers is None:
        max_workers = min(6, os.cpu_count() or 1)

    spherical_image = cv2.imread(spherical_image_path)
    if spherical_image is None:
        raise ValueError(f"画像の読み込みに失敗しました: {spherical_image_path}")

    equirect_height, equirect_width = spherical_image.shape[:2]

    # 指定されていない場合、キューブ面のサイズを自動決定
    if persp_size is None:
        # 元の画像幅の約4分の1を使用
        persp_size = min(max(equirect_width // 4, 512), 4096)
        persp_size = 2 ** int(np.log2(persp_size) + 0.5)
        print(f"自動計算された面のサイズ: {persp_size}px")

    faces_params = {
        "front": {"fov": 90, "theta": 0, "phi": 0},
        "right": {"fov": 90, "theta": 90, "phi": 0},
        "left": {"fov": 90, "theta": -90, "phi": 0},
        "top": {"fov": 90, "theta": 0, "phi": 90},
        "down": {"fov": 90, "theta": 0, "phi": -90},
        "back": {"fov": 90, "theta": 180, "phi": 0},
    }

    image_paths = {}

    # 形式に応じた保存設定
    save_params = []
    file_ext = file_format.lower()

    if file_ext == "jpg" or file_ext == "jpeg":
        save_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
        file_ext = "jpg"
    elif file_ext == "png":
        save_params = [cv2.IMWRITE_PNG_COMPRESSION, min(9, 10 - quality//10)]
        file_ext = "png"
    elif file_ext == "tiff" or file_ext == "tif":
        save_params = [cv2.IMWRITE_TIFF_COMPRESSION, 1]
        file_ext = "tiff"
    else:
        # デフォルトでJPEGを使用
        save_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
        file_ext = "jpg"

    # 1つのキューブ面を処理する関数(マルチスレッド用)
    def process_face(face_name, params):
        try:
            map_x, map_y = eqruirect2persp_map(
                img_shape=(equirect_height, equirect_width),
                FOV=params["fov"],
                THETA=params["theta"],
                PHI=params["phi"],
                Hd=persp_size,
                Wd=persp_size,
                overlap=overlap
            )

            perspective_image = cv2.remap(spherical_image, map_x, map_y, interpolation=interpolation)

            # 黒い縦縞を除去するための "back" 面の後処理
            if face_name == "back":
                perspective_image = fix_back_face_artifact(perspective_image)

            output_filename = f"{camera_label}_{face_name}.{file_ext}"
            output_path = os.path.join(output_folder, output_filename)
            cv2.imwrite(output_path, perspective_image, save_params)
            return face_name, output_path
        except Exception as e:
            print(f"面 {face_name} の処理中にエラーが発生しました: {str(e)}")
            return face_name, None

    # 面の並列処理にThreadPoolExecutorを使用
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        # 面処理タスクを開始
        future_to_face = {executor.submit(process_face, face_name, params): face_name
                          for face_name, params in faces_params.items()}

        # 結果を収集
        for future in concurrent.futures.as_completed(future_to_face):
            face_name = future_to_face[future]
            try:
                face_name, path = future.result()
                if path:
                    image_paths[face_name] = path
            except Exception as e:
                print(f"面 {face_name} の処理中にエラーが発生しました: {str(e)}")

    if not image_paths:
        raise ValueError("キューブ面を1つも作成できませんでした。")

    return image_paths

# === パート4: カメラ追加関数 ===
def add_cubemap_cameras(chunk, spherical_camera, image_paths, persp_size, coord_system="Y_UP"):
    """
    球面カメラの位置に基づいてキューブ面のカメラを追加します。

    Parameters:
    -----------
    chunk : Metashape.Chunk
        アクティブなMetashapeチャンク
    spherical_camera : Metashape.Camera
        元の球面カメラ
    image_paths : dict
        キューブ面の画像パスを含む辞書
    persp_size : int
        透視投影画像のサイズ(幅と高さ)
    coord_system : str
        座標系のタイプ ("Y_UP", "Z_UP", "X_UP")

    Returns:
    --------
    list
        作成されたカメラのリスト
    """
    # 元の球面カメラの位置
    position = spherical_camera.transform.translation()

    # 元の球面カメラの向き
    base_rotation = spherical_camera.transform.rotation()

    # base_rotation (3x3) を 4x4 行列に変換
    def convert_to_4x4(matrix_3x3):
        return Metashape.Matrix([
            [matrix_3x3[0, 0], matrix_3x3[0, 1], matrix_3x3[0, 2], 0],
            [matrix_3x3[1, 0], matrix_3x3[1, 1], matrix_3x3[1, 2], 0],
            [matrix_3x3[2, 0], matrix_3x3[2, 1], matrix_3x3[2, 2], 0],
            [0, 0, 0, 1]
        ])

    base_rotation_4x4 = convert_to_4x4(base_rotation)

    # 異なる座標系用のキューブ面の方向辞書
    cubemap_directions = {}

    # Y_UP系 (Y - 上, Z - 前, X - 右)
    if coord_system == "Y_UP":
        cubemap_directions = {
            "front": {"forward": [0, 0, 1], "up": [0, 1, 0]},
            # ★★★ Right と Left の forward ベクトル定義を入れ替え ★★★
            "right": {"forward": [-1, 0, 0], "up": [0, 1, 0]}, # 元々は[1, 0, 0]だったのをLeftの向きに変更
            "left":  {"forward": [1, 0, 0], "up": [0, 1, 0]},  # 元々は[-1, 0, 0]だったのをRightの向きに変更
            "top": {"forward": [0, 1, 0], "up": [0, 0, -1]},
            "down": {"forward": [0, -1, 0], "up": [0, 0, 1]},
            "back": {"forward": [0, 0, -1], "up": [0, 1, 0]},
        }
    # Z_UP系 (Z - 上, X - 前, Y - 右)
    elif coord_system == "Z_UP":
        cubemap_directions = {
            "front": {"forward": [1, 0, 0], "up": [0, 0, 1]},
             # ★★★ Right と Left の forward ベクトル定義を入れ替え ★★★
            "right": {"forward": [0, -1, 0], "up": [0, 0, 1]}, # 元々は[0, 1, 0]だったのをLeftの向きに変更
            "left":  {"forward": [0, 1, 0], "up": [0, 0, 1]},  # 元々は[0, -1, 0]だったのをRightの向きに変更
            "top": {"forward": [0, 0, 1], "up": [-1, 0, 0]},
            "down": {"forward": [0, 0, -1], "up": [1, 0, 0]},
            "back": {"forward": [-1, 0, 0], "up": [0, 0, 1]},
        }
    # X_UP系 (X - 上, Y - 前, Z - 右)
    elif coord_system == "X_UP":
        cubemap_directions = {
            "front": {"forward": [0, 1, 0], "up": [1, 0, 0]},
            # ★★★ Right と Left の forward ベクトル定義を入れ替え ★★★
            "right": {"forward": [0, 0, -1], "up": [1, 0, 0]}, # 元々は[0, 0, 1]だったのをLeftの向きに変更
            "left":  {"forward": [0, 0, 1], "up": [1, 0, 0]},  # 元々は[0, 0, -1]だったのをRightの向きに変更
            "top": {"forward": [1, 0, 0], "up": [0, -1, 0]},
            "down": {"forward": [-1, 0, 0], "up": [0, 1, 0]},
            "back": {"forward": [0, -1, 0], "up": [1, 0, 0]},
        }
    else:
        print(f"警告: 不明な座標系 '{coord_system}'。Y_UPを使用します。")
        # デフォルト(Y_UP)も同様に入れ替え
        cubemap_directions = {
            "front": {"forward": [0, 0, 1], "up": [0, 1, 0]},
            # ★★★ Right と Left の forward ベクトル定義を入れ替え ★★★
            "right": {"forward": [-1, 0, 0], "up": [0, 1, 0]}, # 元々は[1, 0, 0]だったのをLeftの向きに変更
            "left":  {"forward": [1, 0, 0], "up": [0, 1, 0]},  # 元々は[-1, 0, 0]だったのをRightの向きに変更
            "top": {"forward": [0, 1, 0], "up": [0, 0, -1]},
            "down": {"forward": [0, -1, 0], "up": [0, 0, 1]},
            "back": {"forward": [0, 0, -1], "up": [0, 1, 0]},
        }

    # 回転行列を作成する関数 (前回の修正を維持)
    def create_rotation_matrix(forward, up, base_rotation_4x4):
        # forward ベクトルを正規化
        forward = Metashape.Vector(forward).normalized()
        # right ベクトルを up と forward に垂直なものとして計算 (順序変更)
        up_vec = Metashape.Vector(up) # 元のupベクトルを保持
        right = Metashape.Vector.cross(up_vec, forward).normalized()
        # up ベクトルを right と forward に垂直になるように再計算
        corrected_up = Metashape.Vector.cross(forward, right).normalized()
        # 回転行列を作成
        rotation_matrix = Metashape.Matrix([
            [right.x,         right.y,         right.z,         0],
            [corrected_up.x,  corrected_up.y,  corrected_up.z,  0],
            [forward.x,       forward.y,       forward.z,       0],
            [0,               0,               0,               1]
        ])
        # 球面カメラの基本の向きを適用
        return base_rotation_4x4 * rotation_matrix

    # 各キューブ面にカメラを作成
    cameras_created = []
    for face_name, directions in cubemap_directions.items():
        # 画像がない面はスキップ
        if face_name not in image_paths:
            continue

        # 新しいカメラを作成
        camera = chunk.addCamera()
        # ★★★ カメララベルは元の意味(Right, Left)のままにする ★★★
        camera.label = f"{spherical_camera.label}_{face_name}"

        # --- センサー設定、キャリブレーション、位置設定は変更なし ---
        # 新しいセンサーをコピーまたは作成
        persp_sensors = [s for s in chunk.sensors if s.type == Metashape.Sensor.Type.Frame]
        if persp_sensors:
            camera.sensor = persp_sensors[0]
        else:
            # 適切なセンサーがない場合、新しいものを作成
            sensor = chunk.addSensor()
            sensor.label = f"Perspective_{persp_size}px"
            sensor.type = Metashape.Sensor.Type.Frame
            sensor.width = persp_size
            sensor.height = persp_size
            camera.sensor = sensor

        cameras_created.append(camera)

        # カメラパラメータの設定
        sensor = camera.sensor
        sensor.type = Metashape.Sensor.Type.Frame
        sensor.width = persp_size
        sensor.height = persp_size

        # 視野角90度に対応する焦点距離を設定
        focal_length = persp_size / (2 * np.tan(np.radians(90 / 2)))
        sensor.focal_length = focal_length
        sensor.pixel_width = 1
        sensor.pixel_height = 1

        # カメラ内部パラメータ行列を設定
        calibration = sensor.calibration
        calibration.f = focal_length
        calibration.cx = persp_size / 2
        calibration.cy = persp_size / 2
        calibration.k1 = 0
        calibration.k2 = 0
        calibration.k3 = 0
        calibration.p1 = 0
        calibration.p2 = 0

        # カメラ位置を設定
        camera.transform = Metashape.Matrix.Translation(position)
        # --- ここまで変更なし ---

        # カメラの向きを設定(入れ替えた directions["forward"] を使用)
        forward = directions["forward"]
        up = directions["up"]
        rotation_matrix = create_rotation_matrix(forward, up, base_rotation_4x4)
        camera.transform = camera.transform * rotation_matrix

        # カメラ用のPhotoオブジェクトを作成
        camera.photo = Metashape.Photo()

        # 画像を読み込み(ファイルパスは元のface_nameのものを使用)
        # ★★★ image_paths のキーは元の face_name (Right/Left) を使う ★★★
        if face_name in image_paths:
            camera.photo.path = image_paths[face_name]
        else:
             print(f"エラー: 画像パスが見つかりません face_name={face_name}")
             continue # または適切なエラー処理

        # 画像ファイルが存在するか確認
        if not os.path.exists(camera.photo.path):
            print(f"エラー: 画像ファイルが見つかりません: {camera.photo.path}")
            continue

        # メタデータを更新
        camera.meta['Image/Width'] = str(persp_size)
        camera.meta['Image/Height'] = str(persp_size)
        camera.meta['Image/Orientation'] = "1"

    return cameras_created

# === パート5: GUI用マルチスレッドカメラ処理 ===
if 'PyQt5' in sys.modules:
    from PyQt5.QtCore import QThread, pyqtSignal

    class ProcessCamerasThread(QThread):
        update_progress = pyqtSignal(int, int, str, str, int)  # 進捗, 合計, カメラ名, ステータス, パーセント
        processing_finished = pyqtSignal(bool, dict)  # 成功, 統計
        error_occurred = pyqtSignal(str)  # エラーメッセージ

        def __init__(self, cameras, output_folder, options):
            super().__init__()
            self.cameras = cameras
            self.output_folder = output_folder
            self.options = options
            self.stop_requested = False
            # 面処理用スレッド数
            self.faces_threads = self.options.get("faces_threads", min(6, os.cpu_count() or 1))

        def run(self):
            try:
                start_time = time.time()
                total_cameras = len(self.cameras)
                processed_count = 0
                skipped_count = 0
                errors = []

                # カメラを1つずつ処理(停止可能にするため)
                for i, camera in enumerate(self.cameras):
                    if self.stop_requested:
                        self.processing_finished.emit(False, {
                            "processed": processed_count,
                            "skipped": skipped_count,
                            "total": total_cameras,
                            "time": time.time() - start_time
                        })
                        return

                    try:
                        camera_label = camera.label
                        spherical_image_path = camera.photo.path

                        self.update_progress.emit(
                            i + 1, total_cameras,
                            camera_label, "画像を変換中...",
                            int((i + 0.4) / total_cameras * 100)
                        )

                        # 球面画像をキューブマップ投影に変換
                        # マルチスレッドを使用
                        image_paths = convert_spherical_to_cubemap(
                            spherical_image_path=spherical_image_path,
                            output_folder=self.output_folder,
                            camera_label=camera_label,
                            persp_size=self.options.get("persp_size"),
                            overlap=self.options.get("overlap", 10),
                            file_format=self.options.get("file_format", "jpg"),
                            quality=self.options.get("quality", 95),
                            interpolation=self.options.get("interpolation", cv2.INTER_CUBIC),
                            max_workers=self.faces_threads
                        )

                        # 実際の画像サイズを取得
                        actual_size = cv2.imread(list(image_paths.values())[0]).shape[0]

                        self.update_progress.emit(
                            i + 1, total_cameras,
                            camera_label, f"カメラを追加中 ({actual_size}px)...",
                            int((i + 0.8) / total_cameras * 100)
                        )

                        # キューブ面の新しいカメラを追加
                        add_cubemap_cameras(
                            chunk=Metashape.app.document.chunk,
                            spherical_camera=camera,
                            image_paths=image_paths,
                            persp_size=actual_size,
                            coord_system=self.options.get("coord_system", "Y_UP")
                        )

                        processed_count += 1
                        self.update_progress.emit(
                            i + 1, total_cameras,
                            camera_label, "処理完了",
                            int((i + 1) / total_cameras * 100)
                        )

                    except Exception as e:
                        error_message = f"カメラ {camera.label} の処理中にエラー: {str(e)}"
                        print(error_message)
                        print(traceback.format_exc())
                        errors.append(error_message)
                        skipped_count += 1
                        self.update_progress.emit(
                            i + 1, total_cameras,
                            camera.label, "エラー",
                            int((i + 1) / total_cameras * 100)
                        )

                total_time = time.time() - start_time
                self.processing_finished.emit(True, {
                    "processed": processed_count,
                    "skipped": skipped_count,
                    "total": total_cameras,
                    "time": total_time,
                    "errors": errors
                })

            except Exception as e:
                error_message = f"全体的な処理エラー: {str(e)}"
                print(error_message)
                print(traceback.format_exc())
                self.error_occurred.emit(error_message)

        def stop(self):
            self.stop_requested = True

# === パート6: グラフィカルインターフェース ===
if 'PyQt5' in sys.modules:
    from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                                 QLabel, QPushButton, QProgressBar, QFileDialog,
                                 QMessageBox, QComboBox, QSpinBox, QDoubleSpinBox, QGroupBox)
    from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer

    class CubemapConverterGUI(QMainWindow):
        def __init__(self):
            super().__init__()
            self.init_ui()
            self.process_thread = None

        def init_ui(self):
            # メインウィンドウの設定
            self.setWindowTitle('球面画像からキューブマップへの変換')
            self.setGeometry(100, 100, 800, 600)

            # 中央ウィジェットとレイアウト
            central_widget = QWidget()
            self.setCentralWidget(central_widget)
            main_layout = QVBoxLayout(central_widget)

            # 設定グループ
            settings_group = QGroupBox("設定")
            settings_layout = QVBoxLayout()

            # 出力フォルダの選択
            output_folder_layout = QHBoxLayout()
            self.output_folder_label = QLabel("出力フォルダ:")
            self.output_folder_path = QLabel("未選択")
            self.output_folder_path.setStyleSheet("font-weight: bold;")
            self.browse_button = QPushButton("参照...")
            self.browse_button.clicked.connect(self.select_output_folder)

            output_folder_layout.addWidget(self.output_folder_label)
            output_folder_layout.addWidget(self.output_folder_path, 1)
            output_folder_layout.addWidget(self.browse_button)
            settings_layout.addLayout(output_folder_layout)

            # オーバーラップ設定
            overlap_layout = QHBoxLayout()
            overlap_label = QLabel("オーバーラップ (度):")
            self.overlap_spinner = QDoubleSpinBox()
            self.overlap_spinner.setRange(0, 20)
            self.overlap_spinner.setValue(10)
            self.overlap_spinner.setDecimals(1)
            self.overlap_spinner.setSingleStep(0.5)

            overlap_layout.addWidget(overlap_label)
            overlap_layout.addWidget(self.overlap_spinner)
            settings_layout.addLayout(overlap_layout)

            # キューブ面サイズの選択
            size_layout = QHBoxLayout()
            size_label = QLabel("キューブ面サイズ:")
            self.size_combo = QComboBox()
            self.size_combo.addItem("自動 (推奨)", None)
            for size in [512, 1024, 2048, 4096]:
                self.size_combo.addItem(f"{size}x{size}", size)
            self.size_combo.setCurrentIndex(0)  # デフォルトは自動
            size_layout.addWidget(size_label)
            size_layout.addWidget(self.size_combo)
            settings_layout.addLayout(size_layout)

            # 座標系の選択
            coord_system_layout = QHBoxLayout()
            coord_system_label = QLabel("座標系:")
            self.coord_system_combo = QComboBox()
            self.coord_system_combo.addItems(["Y_UP", "Z_UP", "X_UP", "自動検出"])
            self.coord_system_combo.setCurrentText("自動検出")

            coord_system_layout.addWidget(coord_system_label)
            coord_system_layout.addWidget(self.coord_system_combo)
            settings_layout.addLayout(coord_system_layout)

            # マルチスレッド設定
            thread_layout = QHBoxLayout()
            thread_label = QLabel("スレッド数:")
            self.thread_spinner = QSpinBox()
            self.thread_spinner.setRange(1, os.cpu_count() or 4)
            self.thread_spinner.setValue(min(6, os.cpu_count() or 1))
            self.thread_spinner.setToolTip("キューブ面処理用の並列スレッド数")

            thread_layout.addWidget(thread_label)
            thread_layout.addWidget(self.thread_spinner)
            settings_layout.addLayout(thread_layout)

            # 画像パラメータグループ
            image_group = QGroupBox("画像パラメータ")
            image_layout = QVBoxLayout()

            # ファイル形式の選択
            format_layout = QHBoxLayout()
            format_label = QLabel("ファイル形式:")
            self.format_combo = QComboBox()
            self.format_combo.addItems(["JPEG (JPG)", "PNG", "TIFF"])
            self.format_combo.setCurrentIndex(0)

            format_layout.addWidget(format_label)
            format_layout.addWidget(self.format_combo)
            image_layout.addLayout(format_layout)

            # 画像品質の選択
            quality_layout = QHBoxLayout()
            quality_label = QLabel("品質:")
            self.quality_spinner = QSpinBox()
            self.quality_spinner.setRange(75, 100)
            self.quality_spinner.setValue(95)
            self.quality_spinner.setSingleStep(1)

            quality_layout.addWidget(quality_label)
            quality_layout.addWidget(self.quality_spinner)
            image_layout.addLayout(quality_layout)

            # 補間方法の選択
            interp_layout = QHBoxLayout()
            interp_label = QLabel("補間方法:")
            self.interp_combo = QComboBox()
            self.interp_combo.addItem("最近傍 (高速、低品質)", cv2.INTER_NEAREST)
            self.interp_combo.addItem("線形 (中間)", cv2.INTER_LINEAR)
            self.interp_combo.addItem("キュービック (低速、高品質)", cv2.INTER_CUBIC)
            self.interp_combo.setCurrentIndex(2)  # デフォルトはキュービック

            interp_layout.addWidget(interp_label)
            interp_layout.addWidget(self.interp_combo)
            image_layout.addLayout(interp_layout)

            image_group.setLayout(image_layout)

            # 設定グループをメインレイアウトに追加
            settings_group.setLayout(settings_layout)
            main_layout.addWidget(settings_group)
            main_layout.addWidget(image_group)

            # プロジェクト情報
            info_group = QGroupBox("プロジェクト情報")
            info_layout = QVBoxLayout()

            # カメラ数
            camera_count_layout = QHBoxLayout()
            camera_count_label = QLabel("検出されたカメラ数:")
            self.camera_count_value = QLabel("0")
            self.camera_count_value.setStyleSheet("font-weight: bold;")
            camera_count_layout.addWidget(camera_count_label)
            camera_count_layout.addWidget(self.camera_count_value)
            info_layout.addLayout(camera_count_layout)

            # 検出された座標系
            detected_system_layout = QHBoxLayout()
            detected_system_label = QLabel("検出された座標系:")
            self.detected_system_value = QLabel("未定義")
            self.detected_system_value.setStyleSheet("font-weight: bold;")
            detected_system_layout.addWidget(detected_system_label)
            detected_system_layout.addWidget(self.detected_system_value)
            info_layout.addLayout(detected_system_layout)

            info_group.setLayout(info_layout)
            main_layout.addWidget(info_group)

            # 処理進捗
            progress_group = QGroupBox("処理進捗")
            progress_layout = QVBoxLayout()

            # 全体進捗
            self.progress_bar = QProgressBar()
            progress_layout.addWidget(self.progress_bar)

            # 現在のカメラとステータス
            current_camera_layout = QHBoxLayout()
            current_camera_label = QLabel("現在のカメラ:")
            self.current_camera_value = QLabel("なし")
            current_camera_layout.addWidget(current_camera_label)
            current_camera_layout.addWidget(self.current_camera_value, 1)
            progress_layout.addLayout(current_camera_layout)

            # 処理ステータス
            status_layout = QHBoxLayout()
            status_label = QLabel("ステータス:")
            self.status_value = QLabel("開始待機中")
            status_layout.addWidget(status_label)
            status_layout.addWidget(self.status_value, 1)
            progress_layout.addLayout(status_layout)

            # 残り時間
            time_layout = QHBoxLayout()
            time_label = QLabel("残り時間:")
            self.time_value = QLabel("--:--:--")
            time_layout.addWidget(time_label)
            time_layout.addWidget(self.time_value)
            progress_layout.addLayout(time_layout)

            progress_group.setLayout(progress_layout)
            main_layout.addWidget(progress_group)

            # 操作ボタン
            buttons_layout = QHBoxLayout()

            self.start_button = QPushButton("開始")
            self.start_button.clicked.connect(self.start_processing)
            self.start_button.setMinimumWidth(120)

            self.stop_button = QPushButton("停止")
            self.stop_button.clicked.connect(self.stop_processing)
            self.stop_button.setEnabled(False)
            self.stop_button.setMinimumWidth(120)

            self.close_button = QPushButton("閉じる")
            self.close_button.clicked.connect(self.close)
            self.close_button.setMinimumWidth(120)

            buttons_layout.addStretch()
            buttons_layout.addWidget(self.start_button)
            buttons_layout.addWidget(self.stop_button)
            buttons_layout.addWidget(self.close_button)
            buttons_layout.addStretch()

            main_layout.addLayout(buttons_layout)

            # ステータスバー
            self.status_bar = self.statusBar()
            self.status_bar.showMessage("準備完了")

            # 起動時にプロジェクト情報を更新
            QTimer.singleShot(100, self.update_project_info)

        def select_output_folder(self):
            """画像を保存するフォルダを選択"""
            folder = QFileDialog.getExistingDirectory(self, "画像を保存するフォルダを選択してください")
            if folder:
                self.output_folder_path.setText(folder)

        def update_project_info(self):
            """プロジェクト情報を更新します"""
            try:
                doc = Metashape.app.document
                chunk = doc.chunk

                if not chunk:
                    QMessageBox.warning(self, "エラー", "アクティブなチャンクが見つかりません。")
                    return

                # 球面カメラのリストを取得
                spherical_cameras = [cam for cam in chunk.cameras if cam.transform and cam.photo]
                self.camera_count_value.setText(str(len(spherical_cameras)))

                # 座標系を決定
                if spherical_cameras:
                    try:
                        coord_system = determine_coordinate_system()
                        self.detected_system_value.setText(coord_system)
                    except Exception as e:
                        print(f"座標系の決定中にエラー: {str(e)}")
                        self.detected_system_value.setText("決定エラー")
                else:
                    self.status_bar.showMessage("処理対象の球面カメラが見つかりません")
                    self.detected_system_value.setText("決定不可")

            except Exception as e:
                QMessageBox.warning(self, "エラー", f"プロジェクト情報の取得に失敗しました: {str(e)}")

        def start_processing(self):
            """カメラ処理プロセスを開始します"""
            try:
                # 出力フォルダを確認
                output_folder = self.output_folder_path.text()
                if output_folder == "未選択" or not os.path.exists(output_folder):
                    QMessageBox.warning(self, "エラー", "存在する出力フォルダを選択してください。")
                    return

                # インターフェースから設定を取得
                overlap = self.overlap_spinner.value()
                persp_size = self.size_combo.currentData()  # 自動サイズの場合はNone

                # 選択された座標系を取得
                coord_system_option = self.coord_system_combo.currentText()
                if coord_system_option == "自動検出":
                    coord_system = determine_coordinate_system()
                else:
                    coord_system = coord_system_option

                # 画像設定を取得
                format_text = self.format_combo.currentText()
                if "JPEG" in format_text:
                    file_format = "jpg"
                elif "PNG" in format_text:
                    file_format = "png"
                elif "TIFF" in format_text:
                    file_format = "tiff"
                else:
                    file_format = "jpg"

                quality = self.quality_spinner.value()
                interpolation = self.interp_combo.currentData()

                # マルチスレッド設定を取得
                faces_threads = self.thread_spinner.value()

                # 球面カメラのリストを取得
                doc = Metashape.app.document
                chunk = doc.chunk

                if not chunk:
                    QMessageBox.warning(self, "エラー", "アクティブなチャンクが見つかりません。")
                    return

                spherical_cameras = [cam for cam in chunk.cameras if cam.transform and cam.photo]

                if not spherical_cameras:
                    QMessageBox.warning(self, "エラー", "処理対象の球面カメラが見つかりません。")
                    return

                # 処理スレッドを作成して開始
                self.process_thread = ProcessCamerasThread(
                    cameras=spherical_cameras,
                    output_folder=output_folder,
                    options={
                        "persp_size": persp_size,
                        "overlap": overlap,
                        "coord_system": coord_system,
                        "file_format": file_format,
                        "quality": quality,
                        "interpolation": interpolation,
                        "faces_threads": faces_threads
                    }
                )

                # シグナルを接続
                self.process_thread.update_progress.connect(self.update_progress)
                self.process_thread.processing_finished.connect(self.processing_finished)
                self.process_thread.error_occurred.connect(self.processing_error)

                # インターフェースを更新
                self.start_button.setEnabled(False)
                self.stop_button.setEnabled(True)
                self.progress_bar.setValue(0)
                self.status_value.setText("処理中...")
                self.status_bar.showMessage(f"{len(spherical_cameras)}個のカメラを{faces_threads}スレッドで処理中...")

                # スレッドを開始
                self.process_thread.start()

                # 残り時間更新用のタイマーを開始
                self.start_time = time.time()
                self.timer = QTimer()
                self.timer.timeout.connect(self.update_remaining_time)
                self.timer.start(1000)  # 毎秒更新

            except Exception as e:
                error_message = f"処理開始エラー: {str(e)}\n\n{traceback.format_exc()}"
                print(error_message)
                QMessageBox.critical(self, "エラー", error_message)

        # === パート7: GUIメソッド (続き) ===
        # CubemapConverterGUIクラスのメソッド定義の続き
        def stop_processing(self):
            """処理プロセスを停止します"""
            if self.process_thread and self.process_thread.isRunning():
                reply = QMessageBox.question(
                    self, '確認',
                    '処理を中断してもよろしいですか?',
                    QMessageBox.Yes | QMessageBox.No, QMessageBox.No
                )

                if reply == QMessageBox.Yes:
                    self.status_value.setText("中断中...")
                    self.status_bar.showMessage("処理を中断中...")
                    self.process_thread.stop()

        def update_progress(self, current, total, camera_name, status, progress_percent):
            """進捗情報を更新します"""
            self.progress_bar.setValue(progress_percent)
            self.current_camera_value.setText(camera_name)
            self.status_value.setText(status)

        def update_remaining_time(self):
            """残り時間の推定値を更新します"""
            if self.process_thread and self.process_thread.isRunning():
                elapsed = time.time() - self.start_time
                progress = self.progress_bar.value() / 100.0

                if progress > 0:
                    estimated_total = elapsed / progress
                    remaining = estimated_total - elapsed
                    self.time_value.setText(format_time(remaining))

        def processing_finished(self, success, stats):
            """処理完了時に呼び出されます"""
            if hasattr(self, 'timer') and self.timer:
                self.timer.stop()

            self.start_button.setEnabled(True)
            self.stop_button.setEnabled(False)

            # 結果メッセージを作成
            if success:
                message = f"処理完了!\n\n"
                message += f"合計カメラ数: {stats['total']}\n"
                message += f"正常に処理: {stats['processed']}\n"
                message += f"スキップ/エラー: {stats['skipped']}\n"
                message += f"合計時間: {format_time(stats['time'])}"

                if stats['skipped'] > 0 and 'errors' in stats and stats['errors']:
                    message += f"\n\nエラー ({len(stats['errors'])}件):\n"
                    for i, error in enumerate(stats['errors'][:5]):  # 最初の5件のエラーを表示
                        message += f"{i+1}. {error}\n"
                    if len(stats['errors']) > 5:
                        message += f"... その他 {len(stats['errors']) - 5} 件のエラーがあります。"

                QMessageBox.information(self, "完了", message)
                self.status_value.setText("完了")
                self.status_bar.showMessage(f"処理完了。 正常: {stats['processed']}, エラー: {stats['skipped']}")
            else:
                message = "処理はユーザーによって中断されました。"
                QMessageBox.warning(self, "中断", message)
                self.status_value.setText("中断")
                self.status_bar.showMessage("処理はユーザーによって中断されました")

        def processing_error(self, error_message):
            """処理プロセス中にエラーが発生した場合に呼び出されます"""
            QMessageBox.critical(self, "エラー", f"エラーが発生しました:\n\n{error_message}")
            self.start_button.setEnabled(True)
            self.stop_button.setEnabled(False)
            self.status_value.setText("エラー")
            self.status_bar.showMessage("処理エラー")

        def closeEvent(self, event):
            """ウィンドウのクローズイベントを処理します"""
            if self.process_thread and self.process_thread.isRunning():
                reply = QMessageBox.question(
                    self, '確認',
                    '処理は完了していません。終了してもよろしいですか?',
                    QMessageBox.Yes | QMessageBox.No, QMessageBox.No
                )

                if reply == QMessageBox.Yes:
                    self.process_thread.stop()
                    self.process_thread.wait(1000)  # 終了のために少し時間を与える
                    event.accept()
                else:
                    event.ignore()

# === マルチスレッドによるコンソール処理関数 ===
def process_images_console():
    """
    画像処理関数のコンソール版。
    処理を高速化するためにマルチスレッドを使用します。
    """
    try:
        # アクティブなドキュメントとチャンクがあるか確認
        doc = Metashape.app.document
        if not doc:
            show_message("エラー", "アクティブなMetashapeドキュメントが見つかりません。")
            return

        chunk = doc.chunk
        if not chunk:
            show_message("エラー", "ドキュメント内にアクティブなチャンクが見つかりません。")
            return

        # 球面カメラのリストを取得
        spherical_cameras = [cam for cam in chunk.cameras if cam.transform and cam.photo]

        if not spherical_cameras:
            show_message("エラー", "処理対象の球面カメラが見つかりません。")
            return

        # 出力フォルダの要求
        output_folder = Metashape.app.getExistingDirectory("画像を保存するフォルダを選択してください")
        if not output_folder:
            return

        # オーバーラップ値の要求
        overlap = Metashape.app.getFloat("オーバーラップ値を入力してください (0-20)", 10.0)
        if overlap is None:
            overlap = 10.0  # デフォルト値を設定
        if overlap < 0 or overlap > 20:
            show_message("エラー", "オーバーラップ値は0から20の範囲である必要があります。")
            return

        # キューブ面サイズの要求
        size_options = ["自動", "512x512", "1024x1024", "2048x2048", "4096x4096"]
        selected_size = get_string_option("キューブ面のサイズを選択してください", size_options)

        persp_size = None  # デフォルトは自動サイズ
        if selected_size != "自動":
            persp_size = int(selected_size.split("x")[0])

        # ファイル形式の要求
        format_options = ["jpg", "png", "tiff"]
        file_format = get_string_option("ファイル形式を選択してください (jpg, png, tiff)", format_options)

        # 品質の要求
        quality = Metashape.app.getInt("画像品質を入力してください (75-100)", 95, 75, 100)

        # 補間方法の要求
        interp_options = ["最近傍", "線形", "キュービック"]
        interp_option = get_string_option("補間方法を選択してください", interp_options)

        interpolation = cv2.INTER_CUBIC  # デフォルト
        if interp_option == "最近傍":
            interpolation = cv2.INTER_NEAREST
        elif interp_option == "線形":
            interpolation = cv2.INTER_LINEAR

        # 座標系を決定
        coord_system = determine_coordinate_system()
        print(f"決定された座標系: {coord_system}")

        # スレッド数の要求
        max_cpus = os.cpu_count() or 4
        faces_threads = Metashape.app.getInt("処理スレッド数を入力してください (1-{})".format(max_cpus),
                                               min(6, max_cpus), 1, max_cpus)

# === パート8: コンソール処理とメイン関数 ===
        # 処理を開始
        print(f"{len(spherical_cameras)}個のカメラを{faces_threads}スレッドで処理開始...")
        print(f"設定: オーバーラップ={overlap}, 面サイズ={selected_size}, 座標系={coord_system}")
        print(f"形式={file_format}, 品質={quality}, 補間={interp_option}")

        start_time = time.time()
        processed_count = 0
        skipped_count = 0

        # 情報メッセージを表示
        info_message = (f"{len(spherical_cameras)}個のカメラの処理を開始します。\n\n"
                        f"設定:\n"
                        f"- オーバーラップ: {overlap} 度\n"
                        f"- 面サイズ: {selected_size}\n"
                        f"- 座標系: {coord_system}\n"
                        f"- ファイル形式: {file_format}\n"
                        f"- 品質: {quality}\n"
                        f"- 補間: {interp_option}\n"
                        f"- スレッド数: {faces_threads}\n\n"
                        f"結果は以下に保存されます:\n{output_folder}")
        show_message("情報", info_message)

        # カメラ処理
        for i, camera in enumerate(spherical_cameras):
            try:
                camera_label = camera.label
                spherical_image_path = camera.photo.path

                print(f"\n[{i+1}/{len(spherical_cameras)}] {camera_label} を処理中...")
                console_progress_bar(i + 1, len(spherical_cameras),
                                     prefix='全体進捗:',
                                     suffix=f'({i+1}/{len(spherical_cameras)})',
                                     length=40)

                print(f"  {faces_threads}スレッドを使用して画像を変換中...")

                # 球面画像をキューブマップ投影に変換
                # 面の並列処理を使用
                image_paths = convert_spherical_to_cubemap(
                    spherical_image_path=spherical_image_path,
                    output_folder=output_folder,
                    camera_label=camera_label,
                    persp_size=persp_size,
                    overlap=overlap,
                    file_format=file_format,
                    quality=quality,
                    interpolation=interpolation,
                    max_workers=faces_threads
                )

                # 実際の画像サイズを取得(自動の場合)
                actual_size = cv2.imread(list(image_paths.values())[0]).shape[0]
                print(f"  カメラを追加中 (面サイズ: {actual_size}px)...")

                # キューブ面の新しいカメラを追加
                add_cubemap_cameras(
                    chunk=chunk,
                    spherical_camera=camera,
                    image_paths=image_paths,
                    persp_size=actual_size,
                    coord_system=coord_system
                )

                processed_count += 1
                print(f"  カメラ {camera_label} の処理が成功しました")

                # 進捗情報を更新
                elapsed_time = time.time() - start_time
                progress = (i + 1) / len(spherical_cameras) * 100
                remaining_time = elapsed_time / (i + 1) * (len(spherical_cameras) - i - 1) if (i + 1) < len(spherical_cameras) else 0

                print(f"進捗: {progress:.1f}% | 経過: {format_time(elapsed_time)} | 残り: {format_time(remaining_time)}")

            except Exception as e:
                print(f"カメラ {camera.label} の処理中にエラー: {str(e)}")
                print(traceback.format_exc())
                skipped_count += 1

        # 処理完了
        total_time = time.time() - start_time

        # 最終レポートを表示
        result_message = (f"処理完了!\n\n"
                          f"合計カメラ数: {len(spherical_cameras)}\n"
                          f"正常に処理: {processed_count}\n"
                          f"スキップ/エラー: {skipped_count}\n"
                          f"合計時間: {format_time(total_time)}")
        show_message("処理完了", result_message)

        print("\n==== 処理結果 ====")
        print(f"合計カメラ数: {len(spherical_cameras)}")
        print(f"正常に処理: {processed_count}")
        print(f"スキップ/エラー: {skipped_count}")
        print(f"合計時間: {format_time(total_time)}")

    except Exception as e:
        error_message = f"エラー: {str(e)}\n\n{traceback.format_exc()}"
        print(error_message)
        try:
            show_message("エラー", f"処理中にエラーが発生しました:\n\n{str(e)}")
        except:
            print("エラーダイアログを表示できませんでした。")

# === GUIウィンドウを保持するグローバル変数 ===
gui_window = None

# === メイン実行関数 ===
def main():
    """
    Metashapeから起動するためのメイン関数。
    PyQt5が利用可能かどうかに応じてGUIまたはコンソールモードを選択します。
    """
    global gui_window

    print("=== 球面画像からキューブマップへの変換 ===")
    print("バージョン 1.0.0 - マルチスレッドによる最適化版")

    # GUI使用の可否を確認
    if use_gui:
        try:
            # QApplicationの初期化
            app = QApplication.instance()
            if app is None:
                app = QApplication(sys.argv)

            # ウィンドウの作成と表示
            gui_window = CubemapConverterGUI()
            gui_window.show()

            # Qtのメインイベントループを開始
            print("グラフィカルインターフェースが正常に起動しました")

            # 起動モードを要求
            try:
                non_blocking = Metashape.app.getBool("インターフェースを非ブロッキングモードで起動しますか?")
                print(f"{'非' if non_blocking else ''}ブロッキングモードが選択されました")

                if non_blocking:
                    # 非ブロッキングモードの場合、ウィンドウをグローバル変数に保存するだけ
                    # 1秒後に起動成功メッセージを表示
                    def show_success_message():
                        QMessageBox.information(None, "情報",
                                                "インターフェースは正常に起動しました。このメッセージが表示されていれば、正常に動作しています。")
                    QTimer.singleShot(1000, show_success_message)
                else:
                    # ブロッキングモードの場合、イベントループを開始
                    print("Qtイベント処理ループを開始中...")
                    app.exec_()
                    print("Qtイベント処理ループが完了しました")
            except:
                # モード要求が機能しない場合、デフォルトで非ブロッキングモードを使用
                print("動作モードの要求に失敗しました。非ブロッキングモードを使用します。")
                # この場合、明示的に app.exec_() を呼ばない

        except Exception as e:
            print(f"GUI初期化エラー: {str(e)}")
            print(traceback.format_exc())
            print("コンソールモードに切り替えます。")
            process_images_console()
    else:
        # GUIが利用できない場合、コンソールモードを起動
        process_images_console()

# スクリプトの実行
if __name__ == "__main__":
    main()

以上!

このコードはここに掲載するよりもプルリクするなりするのが良いのだろうけれど、そのあたりのお作法がまだよくわかってないので、とりいそぎここに載せておきました。

ホロラボのテックブログ

Discussion

alexalex

всех приветствую ,вижу вы заинтерсовались моим скриптом ,на данный момент 12 версия последняя если у вас есть предложения и идеи пишите мне в телеграмм https://t.me/ostrix_team_VFX