1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

image.png
f2.8 50mm 1sec ISO2000 15枚合成      シングルショット

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

image.png

簡単な使い方

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

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

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

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

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

4.保存
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("1.画像を開く")
        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("3.ヒストグラム表示演算")
        self.compute_button.clicked.connect(self.compute_image)
        self.compute_button.setEnabled(False)  # 基準点が確定されるまで無効

        self.save_button = QPushButton("4.保存")
        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("2.基準点を確定")
        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("3.ヒストグラム表示演算")
                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"3.ヒストグラム表示演算(基準点{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_())


1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?