星や彗星の写真を取ったときに、1枚の画像では画質が悪いので、複数枚の画像を合成してきれいにしたいことがある。
以下の例のように、固定撮影でも複数枚、星の位置を合わせながら平均を取るとだいぶきれいになる。
f2.8 50mm 1sec ISO2000 15枚合成 シングルショット
市販のソフト、ステライメージでも可能だが保有しているバージョンが古すぎて、今どきの高解像度カメラの画像が処理できなかった。
このため、位置を合わせながら合成できるソフトを作成した。
簡単な使い方
画面の案内通りですが、
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で実行しています。
実行スクリプト
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_())