星野・彗星写真のコンポジット（平均化による画質向上）


星や彗星の写真を取ったときに、１枚の画像では画質が悪いので、複数枚の画像を合成してきれいにしたいことがある。
以下の例のように、固定撮影でも複数枚、星の位置を合わせながら平均を取るとだいぶきれいになる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1520250/08f6a091-458d-23ed-38d9-ab416308679c.png)
f2.8 50mm 1sec ISO2000 15枚合成　　　　　　シングルショット


市販のソフト、ステライメージでも可能だが保有しているバージョンが古すぎて、今どきの高解像度カメラの画像が処理できなかった。
このため、位置を合わせながら合成できるソフトを作成した。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1520250/7a0b965c-b054-71ef-f14a-d7b8e1d94cfa.png)



## 簡単な使い方

画面の案内通りですが、
**1．画像を開く**
ボタンで画像を開く。複数枚の選択が可能
近い時刻の同じ構図の画像を選ぶ
代表の１枚がプレビューされる

ズームインしながら、合成時の基準となる星を選ぶ。
探索範囲を狭くすることや閾値を調整することで、基準星が検出しやすくなる。

**２．基準点を確定**
右下のグリーンの十字がねらった星に重なれば、確定ボタンを押す。
1つなら、平行移動による位置合わせ。
複数なら、アフィン変換を行う。

彗星を基準にしても案外上手く行く

**３．ヒストグラム表示**
簡単なレベル調整が、ビット深が多いときに出来るとうれしいので、ヒストグラムを表示した。
スライダーで上限・下限を設定

**４．保存**
jpg tiff(8bit) tiff(16bit)を選べる。
保存するときのデフォルト名は、合成に使用したファイル名の範囲とした。

## カスタマイズ

コードがあるので、お好みで。
星の検出アルゴリズムは、手持ちの画像では上手く行っています。
ヒストグラムは対数ですが、常数でも。
画像をマウスでパンするには、以下の部分のコメントを外す
self.setDragMode(QGraphicsView.ScrollHandDrag) 
単なる平均処理なので、露光時間が異なる画像は良くないですが、全画像のレベルを調整してから合成するのも手です。

openCVの特徴点マッチングも使えそうです。
Diffusionモデルを利用してノイズ除去もおもしろそうです。

## 実行環境
python3.8環境で作成
```
pip install opencv-python PyQt5 scipy
```
PyQt5を利用した、GUIソフトです。私はWindowsで実行しています。

実行スクリプト
```star_marge.py

import sys
import cv2
import numpy as np
import re
import os
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QPushButton, QLabel, QFileDialog, QVBoxLayout,
    QWidget, QHBoxLayout, QComboBox, QGridLayout, QScrollArea, QGraphicsView,
    QGraphicsScene, QSlider
)
from PyQt5.QtGui import QPixmap, QImage, QTransform
from PyQt5.QtCore import Qt, pyqtSignal, QPoint
from scipy.optimize import curve_fit  # 追加

class ImageViewer(QGraphicsView):
    clicked = pyqtSignal(QPoint)

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setScene(QGraphicsScene(self))
        self.pixmap_item = None
        self.zoom_scale = 1.0
        # self.setDragMode(QGraphicsView.ScrollHandDrag)
        self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)

    def setImage(self, image, reset_zoom=True):
        qImg = self.convert_cv_to_qt(image)
        pixmap = QPixmap.fromImage(qImg)
        if self.pixmap_item is None:
            self.pixmap_item = self.scene().addPixmap(pixmap)
        else:
            self.pixmap_item.setPixmap(pixmap)
        self.setSceneRect(self.pixmap_item.boundingRect())
        if reset_zoom:
            # 画像全体が表示されるように調整
            self.fitInView(self.pixmap_item, Qt.KeepAspectRatio)
            self.zoom_scale = 1.0

    def convert_cv_to_qt(self, image):
        height, width = image.shape[:2]
        if image.dtype == np.uint16:
            image = (image / 256).astype(np.uint8)
        if len(image.shape) == 2:
            bytesPerLine = width
            qImg = QImage(image.data.tobytes(), width, height, bytesPerLine, QImage.Format_Grayscale8)
        else:
            channel = image.shape[2]
            bytesPerLine = channel * width
            if channel == 3:
                qImg = QImage(image.data.tobytes(), width, height, bytesPerLine, QImage.Format_RGB888)
                qImg = qImg.rgbSwapped()
            elif channel == 4:
                qImg = QImage(image.data.tobytes(), width, height, bytesPerLine, QImage.Format_RGBA8888)
            else:
                print("対応していない画像形式です。")
                return None
        return qImg

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            scene_pos = self.mapToScene(event.pos())
            self.clicked.emit(scene_pos.toPoint())
        super().mousePressEvent(event)

    def zoom_in(self):
        self.zoom_scale *= 1.2
        self.scale(1.2, 1.2)

    def zoom_out(self):
        self.zoom_scale /= 1.2
        self.scale(1/1.2, 1/1.2)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("星画像合成アプリケーション")
        self.setGeometry(100, 100, 1200, 800)

        self.images = []
        self.image_files = []  # 画像ファイル名のリスト
        self.reference_points = []  # 基準点のリスト
        self.detected_points_list = []  # 各基準点での検出結果のリスト
        self.current_point_index = 0  # 現在の基準点のインデックス
        self.threshold_value = 200  # デフォルトの閾値
        self.min_value = 0  # レベル調整の最低値
        self.max_value = 255  # レベル調整の最高値
        self.preview_image = None  # プレビュー用の画像
        self.full_resolution_average = None  # フル解像度の平均化された画像
        self.search_radius = 100  # 基準点の検索半径
        self.detection_method = "Centroid"  # デフォルトの検出方法

        # GUI要素の作成
        self.open_button = QPushButton("１．画像を開く")
        self.open_button.clicked.connect(self.open_images)

        self.format_combo = QComboBox()
        self.format_combo.addItems(["jpg", "tiff 8ビット", "tiff 16ビット"])

        self.compute_button = QPushButton("３．ヒストグラム表示演算")
        self.compute_button.clicked.connect(self.compute_image)
        self.compute_button.setEnabled(False)  # 基準点が確定されるまで無効

        self.save_button = QPushButton("４．保存")
        self.save_button.clicked.connect(self.save_image)
        self.save_button.setEnabled(False)  # 演算が完了するまで無効

        self.zoom_in_button = QPushButton("ズームイン")
        self.zoom_in_button.clicked.connect(self.zoom_in)

        self.zoom_out_button = QPushButton("ズームアウト")
        self.zoom_out_button.clicked.connect(self.zoom_out)

        self.confirm_button = QPushButton("２．基準点を確定")
        self.confirm_button.clicked.connect(self.confirm_reference_point)
        self.confirm_button.setEnabled(False)  # 画像を開くまで無効

        # 閾値調整用のスライダーとラベル
        self.threshold_slider = QSlider(Qt.Horizontal)
        self.threshold_slider.setMinimum(100)
        self.threshold_slider.setMaximum(255)
        self.threshold_slider.setValue(self.threshold_value)
        self.threshold_slider.valueChanged.connect(self.update_threshold)
        self.threshold_slider.setEnabled(False)  # 画像を開くまで無効
        self.threshold_label = QLabel(f"閾値: {self.threshold_value}")

        # 検索範囲選択用のコンボボックスとラベル
        self.search_radius_combo = QComboBox()
        self.search_radius_combo.addItems(["200", "300", "400"])
        self.search_radius_combo.currentIndexChanged.connect(self.update_search_radius)
        self.search_radius_label = QLabel("検索範囲の幅 (ピクセル):")

        # 検出方法選択用のコンボボックスとラベル
        self.detection_method_combo = QComboBox()
        self.detection_method_combo.addItems(["重心 (デフォルト)", "ガウスフィッティング"])
        self.detection_method_combo.currentIndexChanged.connect(self.update_detection_method)
        self.detection_method_label = QLabel("星の検出方法:")

        # レベル調整用のヒストグラムとスライダー
        self.figure = plt.figure(figsize=(4, 3))
        self.canvas = FigureCanvas(self.figure)
        self.canvas.setFixedHeight(200)
        self.min_slider = QSlider(Qt.Horizontal)
        self.min_slider.setMinimum(0)
        self.min_slider.setMaximum(255)
        self.min_slider.setValue(self.min_value)
        self.min_slider.valueChanged.connect(self.update_min_value)
        self.min_slider.setEnabled(False)
        self.min_label = QLabel(f"最小値: {self.min_value}")

        self.max_slider = QSlider(Qt.Horizontal)
        self.max_slider.setMinimum(0)
        self.max_slider.setMaximum(255)
        self.max_slider.setValue(self.max_value)
        self.max_slider.valueChanged.connect(self.update_max_value)
        self.max_slider.setEnabled(False)
        self.max_label = QLabel(f"最大値: {self.max_value}")

        # 演算結果のプレビュー
        self.result_preview = QLabel()
        self.result_preview.setFixedSize(400, 400)

        # プレビューラベルをImageViewerに置き換え
        self.preview_label = ImageViewer()
        self.preview_label.setMinimumSize(800, 600)
        self.preview_label.clicked.connect(self.get_reference_point)

        # 小さなプレビュー表示用のラベルを格納するリスト
        self.small_preview_labels = []

        # 小さなプレビュー表示用のレイアウト
        self.small_preview_layout = QGridLayout()

        # スクロールエリアを使用して小さなプレビューを表示
        self.scroll_area = QScrollArea()
        self.scroll_widget = QWidget()
        self.scroll_widget.setLayout(self.small_preview_layout)
        self.scroll_area.setWidget(self.scroll_widget)
        self.scroll_area.setWidgetResizable(True)
        self.scroll_area.setFixedHeight(200)

        # レイアウト設定
        left_layout = QVBoxLayout()
        left_layout.addWidget(self.open_button)
        left_layout.addWidget(self.zoom_in_button)
        left_layout.addWidget(self.zoom_out_button)
        left_layout.addWidget(self.confirm_button)
        left_layout.addWidget(self.threshold_label)
        left_layout.addWidget(self.threshold_slider)
        left_layout.addWidget(self.search_radius_label)
        left_layout.addWidget(self.search_radius_combo)
        left_layout.addWidget(self.detection_method_label)
        left_layout.addWidget(self.detection_method_combo)
        left_layout.addWidget(self.compute_button)
        left_layout.addWidget(self.canvas)
        left_layout.addWidget(self.min_label)
        left_layout.addWidget(self.min_slider)
        left_layout.addWidget(self.max_label)
        left_layout.addWidget(self.max_slider)
        left_layout.addWidget(self.result_preview)
        left_layout.addWidget(self.save_button)
        left_layout.addWidget(self.format_combo)
        left_layout.addStretch()

        right_layout = QVBoxLayout()
        right_layout.addWidget(self.preview_label)
        right_layout.addWidget(self.scroll_area)

        main_layout = QHBoxLayout()
        main_layout.addLayout(left_layout, 1)  # 左側を1とする
        main_layout.addLayout(right_layout, 3)  # 右側を3とする

        container = QWidget()
        container.setLayout(main_layout)
        self.setCentralWidget(container)

    def update_search_radius(self, index):
        # 選択した値を検索範囲の幅として扱い、半径を計算
        width_values = [200, 300, 400]  # 検索範囲の幅
        self.search_radius = width_values[index] // 2  # 半径に変換
        print(f"検索範囲を{self.search_radius * 2}ピクセル四方に設定しました。")
        if self.current_point_index < len(self.reference_points):
            self.detect_stars()

    def update_detection_method(self, index):
        method_names = ["Centroid", "Gaussian"]
        self.detection_method = method_names[index]
        print(f"検出方法を{'重心' if self.detection_method == 'Centroid' else 'ガウスフィッティング'}に設定しました。")
        if self.current_point_index < len(self.reference_points):
            self.detect_stars()

    def open_images(self):
        options = QFileDialog.Options()
        files, _ = QFileDialog.getOpenFileNames(
            self, "画像を選択", "", "Image Files (*.png *.jpg *.tif *.tiff)", options=options)
        if files:
            self.image_files = files
            self.images = [cv2.imdecode(np.fromfile(file, dtype=np.uint8), cv2.IMREAD_UNCHANGED) for file in files]
            if self.images:
                # 最初の画像をプレビューに表示
                first_image = self.images[0]
                self.display_image(first_image, reset_zoom=True)
                self.reference_points = []
                self.detected_points_list = []
                self.current_point_index = 0
                self.confirm_button.setEnabled(True)
                self.compute_button.setEnabled(False)
                self.save_button.setEnabled(False)
                self.compute_button.setText("３．ヒストグラム表示演算")
                self.threshold_slider.setEnabled(True)
                self.threshold_slider.setValue(200)
                self.threshold_value = 200
                self.threshold_label.setText(f"閾値: {self.threshold_value}")
                self.min_slider.setEnabled(False)
                self.max_slider.setEnabled(False)
                self.min_slider.setValue(0)
                self.max_slider.setValue(255)
                self.min_value = 0
                self.max_value = 255
                self.min_label.setText(f"最小値: {self.min_value}")
                self.max_label.setText(f"最大値: {self.max_value}")
                self.preview_image = None
                self.full_resolution_average = None
                self.result_preview.clear()
                # 小さなプレビューラベルを初期化
                for i in reversed(range(self.small_preview_layout.count())):
                    widget_to_remove = self.small_preview_layout.itemAt(i).widget()
                    self.small_preview_layout.removeWidget(widget_to_remove)
                    widget_to_remove.setParent(None)
                self.small_preview_labels = []
                print("1つ目の基準点を選択してください。")

    def display_image(self, image, reset_zoom=False):
        self.preview_label.setImage(image, reset_zoom=reset_zoom)

    def get_reference_point(self, pos):
        if self.images:
            x = pos.x()
            y = pos.y()
            if len(self.reference_points) <= self.current_point_index:
                self.reference_points.append((int(x), int(y)))
            else:
                self.reference_points[self.current_point_index] = (int(x), int(y))
            self.detect_stars()
        else:
            print("プレビュー画像がありません。")

    def detect_stars(self):
        if len(self.reference_points) == 0:
            return
        if self.current_point_index >= len(self.reference_points):
            return

        # 現在の基準点に対応する検出結果を格納するリスト
        detected_points = []
        # 小さなプレビューラベルを初期化
        for i in reversed(range(self.small_preview_layout.count())):
            widget_to_remove = self.small_preview_layout.itemAt(i).widget()
            self.small_preview_layout.removeWidget(widget_to_remove)
            widget_to_remove.setParent(None)
        self.small_preview_labels = []

        ref_point = self.reference_points[self.current_point_index]

        for idx, image in enumerate(self.images):
            x, y = ref_point
            h, w = image.shape[:2]
            radius = self.search_radius
            x_min = max(0, x - radius)
            y_min = max(0, y - radius)
            x_max = min(w, x + radius)
            y_max = min(h, y + radius)
            roi = image[y_min:y_max, x_min:x_max].copy()

            # 画像のビット深度に応じて処理を調整
            if image.dtype == np.uint8:
                max_value = 255
            elif image.dtype == np.uint16:
                max_value = 65535
            else:
                max_value = 255  # デフォルト

            gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
            # 検査領域の画像を正規化
            gray = cv2.normalize(gray, None, 0, max_value, cv2.NORM_MINMAX)

            # 輝度が閾値以上のピクセルのみを対象にする場合
            _, mask = cv2.threshold(gray, self.threshold_value, max_value, cv2.THRESH_BINARY)
            masked_gray = cv2.bitwise_and(gray, gray, mask=mask.astype(np.uint8))

            if self.detection_method == "Centroid":
                # 重心計算による検出
                total_intensity = np.sum(masked_gray)
                if total_intensity != 0:
                    coords = np.indices((masked_gray.shape))
                    cx = int(np.sum(coords[1] * masked_gray) / total_intensity)
                    cy = int(np.sum(coords[0] * masked_gray) / total_intensity)
                    detected_x = x_min + cx
                    detected_y = y_min + cy
                    detected_points.append((detected_x, detected_y))
                    # 緑の十字線を描画
                    cv2.line(roi, (0, cy), (roi.shape[1], cy), (0, 255, 0), 1)
                    cv2.line(roi, (cx, 0), (cx, roi.shape[0]), (0, 255, 0), 1)
                else:
                    detected_points.append((x, y))  # 検出できなかった場合は基準点を使用
                    cx, cy = x - x_min, y - y_min
                    # 赤の十字線を描画
                    cv2.line(roi, (0, cy), (roi.shape[1], cy), (0, 0, 255), 1)
                    cv2.line(roi, (cx, 0), (cx, roi.shape[0]), (0, 0, 255), 1)
            else:
                # ガウスフィッティングによる検出
                try:
                    x_data = np.linspace(0, gray.shape[1] - 1, gray.shape[1])
                    y_data = np.linspace(0, gray.shape[0] - 1, gray.shape[0])
                    x_data, y_data = np.meshgrid(x_data, y_data)
                    initial_guess = (np.max(masked_gray), gray.shape[1] / 2, gray.shape[0] / 2, 3, 3, 0, np.min(masked_gray))
                    popt, _ = curve_fit(self.twoD_Gaussian, (x_data, y_data), masked_gray.ravel(), p0=initial_guess)
                    cx, cy = popt[1], popt[2]
                    detected_x = x_min + cx
                    detected_y = y_min + cy
                    detected_points.append((detected_x, detected_y))
                    # 緑の十字線を描画
                    cv2.line(roi, (0, int(cy)), (roi.shape[1], int(cy)), (0, 255, 0), 1)
                    cv2.line(roi, (int(cx), 0), (int(cx), roi.shape[0]), (0, 255, 0), 1)
                except Exception as e:
                    print(f"ガウスフィッティングに失敗しました: {e}")
                    detected_points.append((x, y))  # 検出できなかった場合は基準点を使用
                    cx, cy = x - x_min, y - y_min
                    # 赤の十字線を描画
                    cv2.line(roi, (0, cy), (roi.shape[1], cy), (0, 0, 255), 1)
                    cv2.line(roi, (cx, 0), (cx, roi.shape[0]), (0, 0, 255), 1)

            # 小さなプレビューを表示
            display_roi = roi.copy()
            if roi.dtype == np.uint16:
                display_roi = (display_roi / 256).astype(np.uint8)
            qImg = self.preview_label.convert_cv_to_qt(display_roi)
            pixmap = QPixmap.fromImage(qImg)
            label = QLabel()
            label.setPixmap(pixmap.scaled(200, 200, Qt.KeepAspectRatio, Qt.SmoothTransformation))
            self.small_preview_labels.append(label)
            self.small_preview_layout.addWidget(label, idx // 5, idx % 5)

        # 現在の基準点に対応する検出結果を保存
        if len(self.detected_points_list) <= self.current_point_index:
            self.detected_points_list.append(detected_points)
        else:
            self.detected_points_list[self.current_point_index] = detected_points

        # 最初の画像を再度表示（ズームレベルを維持）
        self.display_image(self.images[0], reset_zoom=False)

    def twoD_Gaussian(self, coords, amplitude, xo, yo, sigma_x, sigma_y, theta, offset):
        x, y = coords
        xo = float(xo)
        yo = float(yo)
        a = (np.cos(theta)**2)/(2*sigma_x**2) + (np.sin(theta)**2)/(2*sigma_y**2)
        b = -(np.sin(2*theta))/(4*sigma_x**2) + (np.sin(2*theta))/(4*sigma_y**2)
        c = (np.sin(theta)**2)/(2*sigma_x**2) + (np.cos(theta)**2)/(2*sigma_y**2)
        g = offset + amplitude*np.exp(- (a*((x - xo)**2) + 2*b*(x - xo)*(y - yo) + c*((y - yo)**2)))
        return g.ravel()

    def update_threshold(self, value):
        self.threshold_value = value
        self.threshold_label.setText(f"閾値: {self.threshold_value}")
        if self.current_point_index < len(self.reference_points):
            self.detect_stars()

    def confirm_reference_point(self):
        if len(self.reference_points) <= self.current_point_index:
            print("基準点が選択されていません。プレビュー画像をクリックして基準点を選択してください。")
            return

        self.current_point_index += 1

        # 基準点が1つ以上確定されたら演算ボタンを有効化
        if len(self.reference_points) >= 1:
            self.compute_button.setEnabled(True)
            self.compute_button.setText(f"３．ヒストグラム表示演算（基準点{len(self.reference_points)}つ）")

        if self.current_point_index >= 3:
            self.confirm_button.setEnabled(False)
            self.threshold_slider.setEnabled(False)
            print("3つの基準点が確定されました。")
        else:
            print(f"{self.current_point_index + 1}つ目の基準点を選択してください。")
            self.threshold_slider.setValue(200)
            self.threshold_value = 200
            self.threshold_label.setText(f"閾値: {self.threshold_value}")
            # 次の基準点が選択されるまでdetect_starsを呼び出さない

    def compute_image(self):
        if len(self.detected_points_list) == 0 or not self.images:
            print("基準点が指定されていません。")
            return

        num_points = len(self.detected_points_list)
        num_images = len(self.images)

        # ダウンサンプリングした画像サイズを設定
        preview_size = (800, 600)  # プレビュー用のサイズ

        # 適切なデータ型で累積画像を初期化
        sum_image = None

        for idx in range(num_images):
            # 元の画像サイズを取得
            original_shape = self.images[idx].shape

            # 画像ごとに対応する検出点を取得
            src_pts = np.array([self.detected_points_list[i][idx] for i in range(num_points)], dtype=np.float32)
            dst_pts = np.array([self.detected_points_list[i][0] for i in range(num_points)], dtype=np.float32)

            # 画像をダウンサンプリング
            downsampled_image = cv2.resize(self.images[idx], preview_size, interpolation=cv2.INTER_AREA)

            # 座標もスケーリング
            scale_x = preview_size[0] / original_shape[1]
            scale_y = preview_size[1] / original_shape[0]
            scaled_src_pts = src_pts.copy()
            scaled_src_pts[:, 0] *= scale_x
            scaled_src_pts[:, 1] *= scale_y
            scaled_dst_pts = dst_pts.copy()
            scaled_dst_pts[:, 0] *= scale_x
            scaled_dst_pts[:, 1] *= scale_y

            if num_points == 1:
                # 平行移動
                dx = scaled_dst_pts[0][0] - scaled_src_pts[0][0]
                dy = scaled_dst_pts[0][1] - scaled_src_pts[0][1]
                M = np.float32([[1, 0, dx], [0, 1, dy]])
                shifted_image = cv2.warpAffine(downsampled_image, M, preview_size, borderMode=cv2.BORDER_REPLICATE)
            elif num_points == 2:
                # 相似変換（スケール・回転・平行移動）
                M, _ = cv2.estimateAffinePartial2D(scaled_src_pts.reshape(-1, 1, 2), scaled_dst_pts.reshape(-1, 1, 2))
                shifted_image = cv2.warpAffine(downsampled_image, M, preview_size, borderMode=cv2.BORDER_REPLICATE)
            else:
                # アフィン変換
                M = cv2.getAffineTransform(scaled_src_pts[:3], scaled_dst_pts[:3])
                shifted_image = cv2.warpAffine(downsampled_image, M, preview_size, borderMode=cv2.BORDER_REPLICATE)

            if sum_image is None:
                # sum_imageを初期化
                if shifted_image.dtype == np.uint16:
                    sum_image = np.zeros((preview_size[1], preview_size[0], shifted_image.shape[2]), dtype=np.uint64)
                else:
                    sum_image = np.zeros((preview_size[1], preview_size[0], shifted_image.shape[2]), dtype=np.uint32)

            sum_image += shifted_image

        # 平均画像の計算
        self.preview_image = sum_image / num_images

        # レベル調整のためのヒストグラムを表示
        self.show_histogram()

        # レベル調整スライダーを有効化
        self.min_slider.setEnabled(True)
        self.max_slider.setEnabled(True)

        # 演算結果のプレビューを更新
        self.update_result_preview()

        # 保存ボタンを有効化
        self.save_button.setEnabled(True)

    def show_histogram(self):
        if self.preview_image is None:
            return
        self.figure.clear()
        ax = self.figure.add_subplot(111)
        # グレースケール画像を作成
        if len(self.preview_image.shape) == 3:
            gray = cv2.cvtColor(self.preview_image.astype(np.uint8), cv2.COLOR_BGR2GRAY)
        else:
            gray = self.preview_image.astype(np.uint8)
        # ヒストグラムを計算して表示
        ax.hist(gray.flatten(), bins=256, range=(0, 255), color='black')
        ax.set_yscale('log')  # y軸を対数スケールに設定
        # ax.set_title('輝度ヒストグラム')
        # ax.set_xlabel('輝度値')
        # ax.set_ylabel('ピクセル数')
        self.canvas.draw()

    def update_min_value(self, value):
        self.min_value = value
        self.min_label.setText(f"最小値: {self.min_value}")
        if self.min_value >= self.max_value:
            self.max_value = self.min_value + 1
            self.max_slider.setValue(self.max_value)
            self.max_label.setText(f"最大値: {self.max_value}")
        self.update_result_preview()

    def update_max_value(self, value):
        self.max_value = value
        self.max_label.setText(f"最大値: {self.max_value}")
        if self.max_value <= self.min_value:
            self.min_value = self.max_value - 1
            self.min_slider.setValue(self.min_value)
            self.min_label.setText(f"最小値: {self.min_value}")
        self.update_result_preview()

    def update_result_preview(self):
        if self.preview_image is None:
            return
        # レベル調整を適用
        adjusted_image = np.clip((self.preview_image - self.min_value) * (255 / (self.max_value - self.min_value)), 0, 255)
        adjusted_image = adjusted_image.astype(np.uint8)
        # プレビューを更新
        height, width = adjusted_image.shape[:2]
        if len(adjusted_image.shape) == 2:
            qImg = QImage(adjusted_image.data.tobytes(), width, height, width, QImage.Format_Grayscale8)
        else:
            qImg = QImage(adjusted_image.data.tobytes(), width, height, width * 3, QImage.Format_RGB888)
            qImg = qImg.rgbSwapped()
        pixmap = QPixmap.fromImage(qImg).scaled(self.result_preview.width(), self.result_preview.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
        self.result_preview.setPixmap(pixmap)

    def generate_output_image(self):
        if self.full_resolution_average is None:
            return None
        # レベル調整を適用
        adjusted_image = np.clip((self.full_resolution_average - self.min_value) * (255 / (self.max_value - self.min_value)), 0, 255)
        adjusted_image = adjusted_image.astype(np.uint8)
        return adjusted_image

    def generate_default_filename(self):
        # 画像ファイル名から数字を抽出
        numbers = []
        base_names = []
        for file in self.image_files:
            filename = os.path.basename(file)
            base_names.append(filename)
            nums_in_name = re.findall(r'\d+', filename)
            if nums_in_name:
                numbers.extend([int(num) for num in nums_in_name])
        if numbers:
            min_num = min(numbers)
            max_num = max(numbers)
            # ベース名を共通部分にする
            common_prefix = os.path.commonprefix(base_names)
            default_name = f"{common_prefix}{min_num}_to_{max_num}"
        else:
            default_name = "output_image"
        return default_name

    def save_image(self):
        # 出力形式の選択
        format_selection = self.format_combo.currentText()
        # フル解像度の平均化を実行
        self.compute_full_resolution_average()

        output_image = self.generate_output_image()
        if output_image is None:
            print("出力画像がありません。")
            return

        # デフォルトの保存ファイル名を生成
        default_name = self.generate_default_filename()
        # デフォルトの保存パスを入力画像のパスに設定
        initial_dir = os.path.dirname(self.image_files[0]) if self.image_files else ''
        default_path = os.path.join(initial_dir, default_name)

        # 画像を保存
        options = QFileDialog.Options()
        save_path, _ = QFileDialog.getSaveFileName(
            self, "保存先を選択", default_path, "Image Files (*.{})".format(format_selection.split()[0]), options=options)
        if save_path:
            # cv2.imwriteでは日本語パスが扱えないため、np.fromfileとnp.tofileを使用
            ext = os.path.splitext(save_path)[1].lower()
            if ext in ['.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff']:
                # 一時的にエンコードしてバイトデータを取得
                success, encoded_image = cv2.imencode(ext, output_image)
                if success:
                    encoded_image.tofile(save_path)
                    print("画像を保存しました:", save_path)
                else:
                    print("画像のエンコードに失敗しました。")
            else:
                print("サポートされていないファイル形式です。")
        else:
            print("保存がキャンセルされました。")

    def compute_full_resolution_average(self):
        if len(self.detected_points_list) == 0 or not self.images:
            print("基準点が指定されていません。")
            return

        num_points = len(self.detected_points_list)
        num_images = len(self.images)
        image_shape = self.images[0].shape

        # 適切なデータ型で累積画像を初期化
        if self.images[0].dtype == np.uint16:
            sum_image = np.zeros(image_shape, dtype=np.uint64)
        else:
            sum_image = np.zeros(image_shape, dtype=np.uint32)

        for idx in range(num_images):
            # 画像ごとに対応する検出点を取得
            src_pts = np.array([self.detected_points_list[i][idx] for i in range(num_points)], dtype=np.float32)
            dst_pts = np.array([self.detected_points_list[i][0] for i in range(num_points)], dtype=np.float32)

            if num_points == 1:
                # 平行移動
                dx = dst_pts[0][0] - src_pts[0][0]
                dy = dst_pts[0][1] - src_pts[0][1]
                M = np.float32([[1, 0, dx], [0, 1, dy]])
                shifted_image = cv2.warpAffine(self.images[idx], M, (image_shape[1], image_shape[0]),
                                               borderMode=cv2.BORDER_REPLICATE)
            elif num_points == 2:
                # 相似変換（スケール・回転・平行移動）
                M, _ = cv2.estimateAffinePartial2D(src_pts.reshape(-1, 1, 2), dst_pts.reshape(-1, 1, 2))
                shifted_image = cv2.warpAffine(self.images[idx], M, (image_shape[1], image_shape[0]),
                                               borderMode=cv2.BORDER_REPLICATE)
            else:
                # アフィン変換
                M = cv2.getAffineTransform(src_pts[:3], dst_pts[:3])
                shifted_image = cv2.warpAffine(self.images[idx], M, (image_shape[1], image_shape[0]),
                                               borderMode=cv2.BORDER_REPLICATE)

            sum_image += shifted_image

        # 平均画像の計算
        self.full_resolution_average = sum_image / num_images

    def zoom_in(self):
        self.preview_label.zoom_in()

    def zoom_out(self):
        self.preview_label.zoom_out()

    def closeEvent(self, event):
        plt.close('all')
        event.accept()

if __name__ == '__main__':
    import locale
    locale.setlocale(locale.LC_ALL, '')
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())


```