0
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?

[PySide6/NumPy] すべての画像は波だった?フーリエ変換の『分解』と『再構成』を体感するアプリ開発

Last updated at Posted at 2025-09-20

はじめに

初めて投稿させていただきます。
髙坂庵行と申します。

普段はものづくり系の非 IT 部門で DX 推進をしている独学中年エンジニアです。
稀に画像解析なんかもさせていただくことがあるので、自己研鑽も兼ねて寄稿させていただこうと思った次第であります。

画像のフーリエ変換

この記事を書こうと思ったのは、

『画像が全て周波数の重ね合わせで再現できるってホント?』

という疑問を以前から抱えていたからにほかなりません。
フーリエ変換が画像を様々な周波数の波に分解する操作。
一方の逆フーリエ変換はその逆の操作となるため、波を重ね合われば元の画像を再構成できるのだと言うのです。

こちらで詳しい解説がなされていたので是非参考にしてみてください。
それでもなお信じがたい。それならばやってみようじゃないか。
ということで、それを視覚的に実現するために GUI アプリを作成しました。
本記事で触れている本記事で解説する「2 次元フーリエ変換を視覚的に理解するための GUI アプリ」の全ソースコードは、以下のリポジトリで公開しています。
GitHub リポジトリへのリンク

使用した技術スタック

  • 言語: Python 3.11
  • GUI: PySide6
  • 計算: NumPy, SciPy
  • 画像処理: scikit-image

フーリエ変換 → 逆フーリエ変換

今回は sckit-image に標準で備わっている画像を用いました。
ポイントとなるのはこの部分。

def get_sorted_freq_components(self, fft_shifted: np.ndarray) -> List[Dict]:
    h, w = fft_shifted.shape
    center_x, center_y = h // 2, w // 2

    freq_components = []
    for y in range(h):
        for x in range(w):
            distance = np.sqrt((y - center_y)**2 + (x - center_x)**2)
            freq_components.append({
                "distance": distance,
                "value": fft_shifted[y, x],
                "y": y,
                "x": x
            })

    freq_components.sort(key = lambda item: item["distance"])
    return freq_components

フーリエ変換後、ゼロ周波数成分を中心とした座標系にシフトします。
これをもとに戻す際、変換後の周波数単位を元の座標と紐づけておく必要があります。
辞書型にしてリストに格納し、中心座標からの距離でソートをかけています。

コード全文は以下です。

fft_reconstruction.py
import numpy as np
from typing import List, Dict
from scipy.fftpack import fft2, ifft2, fftshift, ifftshift

from skimage.util import img_as_ubyte
from skimage.color import rgb2gray
from skimage.transform import resize
from skimage.data import (
    astronaut,
    brick,
    camera,
    grass,
    gravel,
    immunohistochemistry,
    moon)

class FFTProcessor:
    """
    Handles all FFT-related image processing tasks, such as
    loading images, performing FFT, sorting frequency components,
    and reconstructing image via inverse FFT.
    """
    def __init__(self, image_size: tuple[int, int] = (256, 256)):
        self.image_size = image_size
        image_dict: Dict[str, np.ndarray] = {
            "Astronaut": astronaut(),
            "Brick": brick(),
            "Camera": camera(),
            "Gravel": gravel(),
            "Grass": grass(),
            "Immunohistochemistry": immunohistochemistry(),
            "Moon": moon()
        }
        self.imgs: Dict[str, np.ndarray] = self._load_and_prepare_images(image_dict)

    def _load_and_prepare_images(self, image_dict: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
        """
        Loads images, converts them to grayscale, and resize them.
        A size that is a power of 2, like 256x256, is optimal for FFT processing.
        """
        prepared_images = {}
        for name, img in image_dict.items():
            if img.ndim == 3:
                img = rgb2gray(img)   # Check if the image is RGB
            # Resize and convert to uint8 (0-255)
            img_resized = resize(img, self.image_size, anti_aliasing=True)
            prepared_images[name] = img_as_ubyte(img_resized)
        return prepared_images

    def generate_shifted_fft(self, img: np.ndarray) -> np.ndarray:
        """
        Performs a 2D FFT and shifts the zero-frequecy component to the center.

        Args:
            img: The input grayscal image as a NumPy array.

        Returns:
            The FFT-shifted complex Numpy array.
        """
        fft_result = fft2(img)
        return fftshift(fft_result)

    def get_sorted_freq_components(self, fft_shifted: np.ndarray) -> List[Dict]:
        """
        Sorts all frequency components of the FFT by their distance from the center.
        Low frequencies (center) come first.

        Args:
            fft_shifted: The FFT-shifted complex NumPy array.

        Returns:
            A list of dictionaries, where each dictionary contains the
            value and coordinates of a frequency component, sorted by magnitude.
        """
        h, w = fft_shifted.shape
        center_x, center_y = h // 2, w // 2

        freq_components = []
        for y in range(h):
            for x in range(w):
                distance = np.sqrt((y - center_y)**2 + (x - center_x)**2)
                freq_components.append({
                    "distance": distance,
                    "value": fft_shifted[y, x],
                    "y": y,
                    "x": x
                })

        freq_components.sort(key = lambda item: item["distance"])
        return freq_components

    def add_frequency_components(self, fft_space_array: np.ndarray, components_to_add: List[Dict]) -> None:
        """
        Adds a list of frequency components to the FFT space array.
        This method modifies the array in-place.

        Args:
            fft_space_array: The complex array representing the image of frequency space.
            components_to_add: A list of frequency component dictionaries to add.
        """
        for component in components_to_add:
            y, x = component["y"], component["x"]
            fft_space_array[y, x] = component["value"]

    def perform_inverse_fft(self, fft_space_array: np.ndarray) -> np.ndarray:
        """
        Performs an inverse FFT to transform a frequency space array back to a spatial image.

        Args:
            fft_space_array: The complex array in frequency space.

        Returns:
            The reconstructed real-valued image as a Numpy array.
        """
        ifft_shifted = ifftshift(fft_space_array)
        # Return the real part of the inverse FFT result
        return ifft2(ifft_shifted).real

GUI 部分

アプリ作成には PySide6 を用いました。
Tkinter や PySimpleGUI の後継である TkEasyGUI でも良かったかもしれませんが、ここいらで PySie6 も覚えておこうという熱意の現れと、スタイリッシュな見た目になるというほんのちょっとした虚栄心が決め手です 😀。

先にコード全文を。

main.py
from __future__ import annotations

import sys
import numpy as np
from typing import List, Dict, Optional
from PySide6.QtCore import Qt, QSize
from PySide6.QtGui import QPixmap, QImage
from PySide6.QtWidgets import (
    QWidget,
    QSpinBox,
    QMessageBox,
    QFrame,
    QVBoxLayout,
    QHBoxLayout,
    QLabel,
    QComboBox,
    QRadioButton,
    QApplication,
    QPushButton,
    QButtonGroup
    )

from fft_reconstruction import FFTProcessor

# --- Constants ---
MODE_COUNT = "Count"
MODE_PERCENT = "Percent"

class MainWindow(QWidget):
    """
    Main application window for visualizing FFT image reconstruction.
    Users can select an image, choose reconstruction parameters,
    and see the image being rebuilt from its frequency components.
    """
    def __init__(self) -> None:
        super().__init__()
        self.fft_processor = FFTProcessor()
        self.images: Dict[str, np.ndarray] = self.fft_processor.imgs

        # --- Application State ---
        self.current_mode: str = MODE_COUNT
        self.is_processing: bool = False
        self._fft_shifted: Optional[np.ndarray] = None
        self._freq_components: Optional[List[Dict]] = None

        self._setup_ui()
        self._connect_signals()

    def _setup_ui(self) -> None:
        """Initialize and arranges all UI components."""
        self.setWindowTitle("FFT Image Reconstructor")
        self.setFixedSize(920, 640)

        main_layout = QVBoxLayout(self)
        main_layout.setContentsMargins(20,20,20,20)

        control_panel = self._create_control_panel()
        viewer_panel = self._create_viewer_panel()

        main_layout.addLayout(control_panel, stretch=1)
        main_layout.addLayout(viewer_panel, stretch=3)

    def _create_control_panel(self) -> QHBoxLayout:
        """Creates the top panel with all user controls."""
        # --- Left Side: Mode, Spin Boxes, Image Selection ---
        self.count_radio_button = QRadioButton(MODE_COUNT)
        self.percent_radio_button = QRadioButton(MODE_PERCENT)
        self.count_radio_button.setChecked(True)

        self.mode_button_group = QButtonGroup(self)
        self.mode_button_group.addButton(self.count_radio_button)
        self.mode_button_group.addButton(self.percent_radio_button)
        self.setStyleSheet("QRadioButton:focus { outline: none; }")

        radio_layout = QHBoxLayout()
        radio_layout.addWidget(self.count_radio_button, alignment=Qt.AlignmentFlag.AlignCenter)
        radio_layout.addWidget(self.percent_radio_button, alignment=Qt.AlignmentFlag.AlignCenter)

        self.start_spin_box = QSpinBox()
        self.stop_spin_box = QSpinBox()

        # Upper and Lower secions
        self.upper_panel = QHBoxLayout()
        self.lower_panel = QHBoxLayout()
        self.step_spin_box = QSpinBox()

        spin_layout = QHBoxLayout()
        for spin_box in [self.start_spin_box, self.stop_spin_box, self.step_spin_box]:
            spin_box.setFixedSize(QSize(180,30))
            spin_layout.addWidget(spin_box, alignment=Qt.AlignmentFlag.AlignCenter)

        self.image_combo_box = QComboBox()
        self.image_combo_box.addItems(self.images.keys())
        self.image_combo_box.setFixedSize(QSize(320, 30))

        self.total_components_label = QLabel("")
        self.progress_label = QLabel("")

        combo_layout = QHBoxLayout()
        combo_layout.addWidget(self.image_combo_box, alignment=Qt.AlignmentFlag.AlignCenter)
        combo_layout.addWidget(self.total_components_label, alignment=Qt.AlignmentFlag.AlignCenter)
        combo_layout.addWidget(self.progress_label, alignment=Qt.AlignmentFlag.AlignCenter)

        left_vbox = QVBoxLayout()
        left_vbox.addLayout(radio_layout)
        left_vbox.addLayout(spin_layout)
        left_vbox.addLayout(combo_layout)

        # --- Right Side: Action Buttons ---
        self.load_button = QPushButton("Load Image")
        self.reconstruct_button = QPushButton("FFT Reconstruction")
        self.stop_button = QPushButton("Stop Processing")
        self.reset_button = QPushButton("Reset Images")

        buttons_vbox = QVBoxLayout()
        button_size = QSize(240, 30)
        for button in [self.load_button, self.reconstruct_button, self.stop_button, self.reset_button]:
            button.setFixedSize(button_size)
            buttons_vbox.addWidget(button)

        # --- Combine Left and Right ---
        control_panel_layout = QHBoxLayout()
        control_panel_layout.addLayout(left_vbox)
        control_panel_layout.addLayout(buttons_vbox)
        return control_panel_layout

    def _create_viewer_panel(self) -> QHBoxLayout:
        """Creates the bottom panel for displaying images."""
        # Lower left sction
        self.original_image_label = QLabel("Original Image", alignment=Qt.AlignmentFlag.AlignCenter)
        self.original_image_frame = QLabel()
        self.original_image_frame.setFrameStyle(QFrame.Shape.Panel)
        self.original_image_frame.setFixedSize(QSize(420, 420))

        left_layout = QVBoxLayout()
        left_layout.addWidget(self.original_image_label)
        left_layout.addWidget(self.original_image_frame)

        self.reconstructed_image_label = QLabel("Reconstructed Image", alignment=Qt.AlignmentFlag.AlignCenter)
        self.reconstructed_image_frame = QLabel()
        self.reconstructed_image_frame.setFrameStyle(QFrame.Shape.Panel)
        self.reconstructed_image_frame.setFixedSize(QSize(420,420))

        right_layout = QVBoxLayout()
        right_layout.addWidget(self.reconstructed_image_label)
        right_layout.addWidget(self.reconstructed_image_frame)

        viewer_panel_layout = QHBoxLayout()
        viewer_panel_layout.addLayout(left_layout)
        viewer_panel_layout.addLayout(right_layout)
        return viewer_panel_layout

    def _connect_signals(self) -> None:
        """Connects all widget signals to corresponding slots."""
        self.mode_button_group.buttonClicked.connect(self._update_reconstruction_mode)
        self.load_button.clicked.connect(self.load_image)
        self.reconstruct_button.clicked.connect(self.run_reconstruction)
        self.stop_button.clicked.connect(self._stop_processing)
        self.reset_button.clicked.connect(self.reset_images)
        self.start_spin_box.valueChanged.connect(self._validate_spin_ranges)
        self.stop_spin_box.valueChanged.connect(self._validate_spin_ranges)

    def load_image(self) -> None:
        """Loads the selected image and prepares if for FFT."""
        image_name = self.image_combo_box.currentText()
        if not image_name:
            self._show_message_box("Alert!", "No image selected", QMessageBox.Icon.Warning)
            return

        img = self.images[image_name]
        self._fft_shifted = self.fft_processor.generate_shifted_fft(img)
        self._freq_components = self.fft_processor.get_sorted_freq_components(self._fft_shifted)

        self.total_components_label.setText(f"{len(self._freq_components)} pcs")
        self._update_spin_box_settings()

        pixmap = self._create_scaled_pixmap(img, self.original_image_frame)
        self.original_image_frame.setPixmap(pixmap)

    def run_reconstruction(self) -> None:
        """Starts the image reconstruction process based on UI settings."""
        if self._fft_shifted is None or self._freq_components is None:
            self._show_message_box("Alert!", "Please load an image first!", QMessageBox.Icon.Warning)
            return

        self.is_processing = True
        fft_space_img = np.zeros_like(self._fft_shifted, dtype=complex)
        total_components = len(self._freq_components)

        start, stop, step = self._get_reconstruction_params(total_components)
        if start >= step:
            return

        component_indices = list(map(int, np.arange(start, stop+1, step)))
        if component_indices[-1] < stop:
            component_indices.append(stop)

        last_added_index = 0
        for i, current_index in enumerate(component_indices):
            if not self.is_processing:
                break

            # Add only the new frequency components for this step
            components_to_add = self._freq_components[last_added_index:current_index]
            self.fft_processor.add_frequency_components(fft_space_img, components_to_add)

            display_img = self.fft_processor.perform_inverse_fft(fft_space_img)
            pixmap = self._create_scaled_pixmap(display_img, self.reconstructed_image_frame)
            self.reconstructed_image_frame.setPixmap(pixmap)

            progress = int(100 * (i + 1) // len(component_indices))
            self.progress_label.setText(f"{progress} %")
            QApplication.processEvents()
            last_added_index = current_index

        message = "Reconstruction completed" if self.is_processing else "Processing was stopped."
        self._show_message_box("Information", message, QMessageBox.Icon.Information)
        self.progress_label.setText("")

    def _get_reconstruction_params(self, total_components: int) -> tuple[int, int, int]:
        """Calculates start, stop, and step values from spin boxes and based on the current mode."""
        if self.current_mode == MODE_COUNT:
            start = self.start_spin_box.value()
            stop = self.stop_spin_box.value()
            step = self.step_spin_box.value()
        else:
            start = int(total_components * self.start_spin_box.value() / 100)
            stop = int(total_components * self.stop_spin_box.value() / 100)
            step = int(total_components * self.step_spin_box.value() / 100)
            if step == 0: step = 1   # Ensure step is at least 1
        return start, stop, step

    def _create_scaled_pixmap(self, img: np.ndarray, frame: QFrame) -> QPixmap:
        """Converts a Numpy array to a QPixmap, scaled to fit the given frame."""
        if img.dtype != np.uint8:
            # Normalize float image to 0-255 range and convert to uint8
            min_val, max_val = np.min(img), np.max(img)
            if min_val == max_val:
                img_norm = np.zeros_like(img)
            else:
                img_norm = (img - min_val) / (max_val - min_val)
            img = (255 * img_norm).astype(np.uint8)

        if not img.flags['C_CONTIGUOUS']:
            img = np.ascontiguousarray(img)

        h, w = img.shape
        bytes_per_line = w
        q_image = QImage(img, w, h, bytes_per_line, QImage.Format.Format_Grayscale8)
        pixmap = QPixmap.fromImage(q_image.copy())

        return pixmap.scaled(
            frame.size(),
            Qt.AspectRatioMode.KeepAspectRatio,
            Qt.TransformationMode.SmoothTransformation
        )

    def reset_images(self) -> None:
        """Clears the image frames and resets the UI controls."""
        self.original_image_frame.clear()
        self.reconstructed_image_frame.clear()
        self.total_components_label.setText("")
        self.progress_label.setText("")
        self.start_spin_box.clear()
        self.stop_spin_box.clear()
        self.step_spin_box.clear()
        self._fft_shifted = None
        self._freq_components = None
        self.is_processing = False

    def _update_reconstruction_mode(self) -> None:
        """Updates the application mode when a radio button is checked."""
        self.current_mode = self.mode_button_group.checkedButton().text()
        if self._freq_components:
            self._update_spin_box_settings()

    def _update_spin_box_settings(self) -> None:
        """Configures spin box ranges and values based on the current mode."""
        total_components = len(self._freq_components)
        if self.current_mode == MODE_COUNT:
            self.start_spin_box.setRange(0, total_components)
            self.stop_spin_box.setRange(0, total_components)
            self.step_spin_box.setRange(1, total_components)
            self.stop_spin_box.setValue(total_components)
            self.step_spin_box.setValue(max(1, total_components // 100))
        else:    # MODE_PERCENT
            self.start_spin_box.setRange(0, 100)
            self.stop_spin_box.setRange(0, 100)
            self.step_spin_box.setRange(1, 100)
            self.stop_spin_box.setValue(100)
            self.step_spin_box.setValue(1)
        self.start_spin_box.setValue(0)

    def _validate_spin_ranges(self) -> None:
        """Ensures that the start value is always less than the stop value by linking their ranges."""
        start_val = self.start_spin_box.value()
        stop_val = self.stop_spin_box.value()
        # Prevent signals from creating an infinite loop, block them tempolarily.
        self.start_spin_box.blockSignals(True)
        self.stop_spin_box.blockSignals(True)
        if start_val >= stop_val:
            self.start_spin_box.setValue(stop_val - 1)

    def _stop_processing(self) -> None:
        """Sets the flag to stop the reconstruction loop."""
        self.is_processing = False

    def _show_message_box(self, title: str, text: str, icon: QMessageBox.Icon) -> None:
        """Display a message box with the given parameters."""
        msg_box = QMessageBox(self)
        msg_box.setWindowTitle(title)
        msg_box.setText(text)
        msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
        msg_box.exec()

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

つまづいたポイント

はい、ありました。
この箇所ですね。

def run_reconstruction(self) -> None:
    if self._fft_shifted is None or self._freq_components is None:
        self._show_message_box("Alert!", "Please load an image first!", QMessageBox.Icon.Warning)
        return

    self.is_processing = True
    fft_space_img = np.zeros_like(self._fft_shifted, dtype=complex)
    total_components = len(self._freq_components)

    start, stop, step = self._get_reconstruction_params(total_components)
    if start >= step:
        return

    component_indices = list(map(int, np.arange(start, stop+1, step)))
    if component_indices[-1] < stop:
        component_indices.append(stop)

    last_added_index = 0
    for i, current_index in enumerate(component_indices):
        if not self.is_processing:
            break

        # Add only the new frequency components for this step
        components_to_add = self._freq_components[last_added_index:current_index]
        self.fft_processor.add_frequency_components(fft_space_img, components_to_add)

        display_img = self.fft_processor.perform_inverse_fft(fft_space_img)
        pixmap = self._create_scaled_pixmap(display_img, self.reconstructed_image_frame)
        self.reconstructed_image_frame.setPixmap(pixmap)

display_img = self.fft_processor.perform_inverse_fft(fft_space_img)で逆フーリエ変換処理、右下のフレームにセットしようとしたら、ndarray is not C-contiguousなるエラーが。
どうやらこれは「QImageに渡された NumPy 配列のデータが、メモリ上で連続して並んでいない」ことを意味するようです。
QImageは、効率化のために NumPy 配列のメモリを直接参照して画像を描画するとのことであり、NumPy と PySide6/PyQt を連携させる際によく起こり得るのだそう。

解決策はのポイントは 2 点

  • iFFT 後のデータ型と値の範囲が QImage に適していない問題を修正
  • メモリが非連続になっている問題を修正
def _create_scaled_pixmap(self, img: np.ndarray, frame: QFrame) -> QPixmap:
    if img.dtype != np.uint8:
        min_val, max_val = np.min(img), np.max(img)
        if min_val == max_val:
            img_norm = np.zeros_like(img)
        else:
            # iFFTの結果はfloat型なので、QImageが要求するuint8 (0-255)に変換する
            img_norm = (img - min_val) / (max_val - min_val)
        img = (255 * img_norm).astype(np.uint8)
     # 配列がC-contiguousでない場合、新しい連続した配列を作成する
    if not img.flags['C_CONTIGUOUS']:
        img = np.ascontiguousarray(img)

    h, w = img.shape
    bytes_per_line = w
    q_image = QImage(img, w, h, bytes_per_line, QImage.Format.Format_Grayscale8)
    pixmap = QPixmap.fromImage(q_image.copy())

    return pixmap.scaled(
        frame.size(),
        Qt.AspectRatioMode.KeepAspectRatio,
        Qt.TransformationMode.SmoothTransformation
    )

完成形

「FFT Reconstruction」ボタンを押すと画像の「逆合成」が始まります。
時間の制約上 STEP を 50 枚にしたため、割とあっという間に戻ってしまっていますが、10 枚単位くらいにすると白と黒とからなる波がジワジワと足し合わされていく様子がよくわかるのではないかと思います。

FFT_Reconstruction_demo.gif

再合成中の切り出しイメージを見ると周波数の重ね合わせであることが視覚的に理解できるかと思います。
100 枚くらい重ね合わせた時点で元画像の特徴がほぼ見えてきていますね。
step-wise_reconstruction.png

おわりに

今回作成したアプリの全ソースコードはこちらのリポジトリで公開しています。

GitHub リポジトリへのリンク

参考にした記事

0
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
0
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?