Pārlūkot izejas kodu

Update: brushup UI.

Satoshi Yoneda 1 mēnesi atpakaļ
vecāks
revīzija
53e5e18bb7

+ 21 - 0
LICENSE.md

@@ -0,0 +1,21 @@
+# MIT License
+
+Copyright (c) 2026 Satoshi Yoneda with Antigravity
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 250 - 12
README.md

@@ -1,19 +1,257 @@
-# DupFind - 画像類似度検索ツール
+# DupFind - 類似画像ファイル検索・整理ツール
 
-現在の開発状況と継続方法について記述します。
+ストレージにある類似した画像ファイルを検索するツールです。Antigravityを利用して初めてバイブコーディングで開発してみました。コードをほとんど書かずに何かを作ったのは初めてですが、それなりに活用できそうなツールができたので公開しておきます。
 
-## 現在のステータス
-- **計画フェーズ**: 実施計画 (`implementation_plan.md`) とタスクリスト (`task.md`) の作成が完了し、ユーザーからのフィードバック(複数ディレクトリ対応、Linux対応)を反映済みです。
-- **未着手**: 実装(CMake プロジェクトのセットアップ以降)はこれから開始する段階です。
+過去の撮影した写真や利用した画像ファイル類を整理したいということは多いと思います。画像の内容で検索するツールはいくつかありますが、本DupFindはシンプルなpHash/dHashを用いることで、高速に類似画像を検索します。また、GUIとCLIを併用することで、効率の良い整理作業ができることが特徴です。
 
-## 継続方法
-作業を再開する際は、このディレクトリにある `implementation_plan.md` と `task.md` を読み込み、以下のステップから開始してください:
-1. CMake プロジェクトの作成と OpenCV/Qt 6/SQLite の依存関係のセットアップ。
-2. コアロジック(ImageHasher)の実装。
+なお、画像の近似判定にpopcnt命令が必要なので、x86ではSSE4.2に対応したCPUが必要です。古いCPUでは動作しない可能性がありますが、SSE4.2に対応していないCPU(Nehalem/初代Coreプロセッサより古いCPU)が現役であるとは考えにくいので、ほとんど問題が起きることはないでしょう。
+
+## 特徴
+
+- pHash/dHashによる高速な類似画像検索
+- CLIを使ってバックグラウンドでの画像ハッシュ計算に対応
+- マルチプラットフォーム(WindowsとLinuxで動作を確認)
+
+## 使い方
+
+書き込み権限があるディレクトリに、実行ファイルとWindowsではDLLなど一式を配置します。配置したディレクトリに設定ファイルDupFind.iniとキャッシュファイルdupfind_cache.dbが自動生成されるので、書き込み権限が必ず必要です。その点は注意してください。
+
+DupFindがGUI、DupFindCmdがCLIです。
+
+### DupFind
+
+GUIのDupFindを起動すると次のようなウィンドウが表示されます。
+
+![DupFindのGUI](img/dupfind.png)
+
+#### Add Directory
+
+検索するディレクトリを追加します。追加したディレクトリはリストに表示されます。ディレクトリは複数指定可能です。
+
+#### Remove Directory
+
+リストで選択したディレクトリを検索対象から外します。リストから削除しても、キャッシュ内の情報は保持されます。
+
+#### Similarity Threshold
+
+検索する際の類似度の閾値を設定します。値が小さいほど類似度が高くなります。デフォルトは10です。
+
+#### Strict Mode
+
+Strictにチェックを入れると、厳密モードで検索します。通常はチェックを入れなくても良いでしょう。
+
+#### Start Scan
+
+検索を開始します。検索中はGUIがフリーズしますが、バックグラウンドで画像ハッシュの計算を行っています。検索が完了すると、結果が表示されます。グループ表示されている類似画像のチェックボックスは、削除候補です。チェックを入れた画像が削除対象になります。デフォルトではファイルサイズが小さいものが削除候補になります。必要に応じてチェックを外してください。
+
+サムネイルをダブルクリックすると、OSで関連付けられているビューアーが起動し、画像を表示します。また、サムネイルを右クリックするとメニューが表示され、「Copy Full Path」で画像のフルパスをクリップボードにコピーでき、「Remove from List」でリストから削除できます。
+
+#### Clear Results
+
+検索結果をクリアします。キャッシュは削除されません。
+
+#### Deselect All
+
+チェックボックスをすべて外します。
+
+#### Delete Selected
+
+チェックを入れた画像を削除します。削除した画像は、OSのゴミ箱に移動します。
+
+### DupFindCmd
+
+DupFindCmdは、ヘルパーコマンドです。CLIで利用します。コマンドパラメータは次の通り。
+
+```console
+DupFindCmd add directory_path
+DupFindCmd th N
+DupFindCmd strict on|off
+DupFindCmd search image_file
+```
+
+#### add directory_path
+
+指定したdirectory_pathを検索対象に追加し、バックグラウンドで情報をキャッシュに格納します。GUIのStart Scanと同じ動作ですが、バックグラウンドで実行され結果は表示されません。大量の画像があるディレクトリを、あらかじめaddコマンドでキャッシュしておくことで、GUIのStart Scanの実行時間を短縮できます。
+
+#### th N
+
+類似度の閾値をNに設定します。Nには0から32の間の値を指定してください。デフォルトは10です。
+
+#### strict on|off
+
+Strictモードをオンまたはオフに設定します。デフォルトはオフです。
+
+#### search image_file
+
+指定したimage_fileと類似する画像をキャッシュ内から検索します。検索時に使われる閾値とStrictモードは現在の設定が使われます。結果は標準出力に出力されます。image_fileの情報はキャッシュされません。
+
+## ビルド方法
+
+### Linux
+
+ビルドにはOpenCV、Qt6、SQLite3が必要です。これらのライブラリ及びヘッダファイル群をインストールしてから、次のコマンドを実行してください。Debian/Ubuntuの場合は次のようにインストールします。
+
+```console
+sudo apt update
+sudo apt install build-essential cmake
+sudo apt install qt6-base-dev qt6-declarative-dev libqt6concurrent6
+sudo apt install libopencv-dev
+sudo apt install libsqlite3-dev
+sudo apt install libprotobuf-dev
+```
+
+ソースコードを適当なディレクトリに配置後、そのディレクトリに移動して次のコマンドを実行します。
+
+```console
+cmake -B build
+cmake --build build
+```
+
+### Windows
+
+Windows上でのビルドにはVisual Stduio 2026を利用しました。vcpkgなどを使ってOpenCV、Qt6、SQLite3をインストールしてください。OpenCVやQtはvcpkgを使うより公式のインストーラーを利用したほうが楽かもしれません。SQLite3はvcpkgで簡単にインストールできるはずです。
+
+Visual Studio 2026でCMakeプロジェクトとして開いてビルドします。CMakeを利用する場合は、環境に合わせてCMakePresets.jsonを作成してください。ソースコードに添付しているCMakePresets.jsonはあくまでサンプルです。
+
+### その他
+
+OSに依存していないので、OpenCV、SQLite3、Qt6がインストールされていれば、macOSやその他のOSでビルドできるはずです。
+
+## 謝辞
+
+DupFindの開発には以下のライブラリを利用しました。感謝いたします。
+
+- OpenCV
+- Qt 6
+- SQLite3
+
+---
+
+# English
+
+# DupFind - Similar Image File Search & Organization Tool
+
+A tool to search for similar image files in your storage. I developed this using Antigravity through "vibe coding" for the first time. It's my first time building something with almost no code written manually, but it turned out to be a quite useful tool, so I am publishing it.
+
+Many people probably want to organize their past photos and image files. Although there are several tools that can search based on image content, DupFind searches for similar images quickly using a simple pHash/dHash. It features efficient organization workflows by combining GUI and CLI tools.
+
+Note: Since the image similarity judgment requires the `popcnt` instruction, an x86 CPU supporting SSE4.2 is required. It might not work on very old CPUs, but since it is highly unlikely that CPUs lacking SSE4.2 support (older than Nehalem / 1st Gen Core processors) are still in active use, this should rarely be an issue.
+
+## Features
+
+- High-speed similar image search using pHash/dHash
+- CLI support for calculating image hashes in the background
+- Multi-platform (Confirmed to work on Windows and Linux)
+
+## Usage
+
+Place the executable along with the necessary DLLs (on Windows) in a directory where you have write permissions. Please be aware that you absolutely need write permissions because the configuration file `DupFind.ini` and the cache file `dupfind_cache.db` will be auto-generated in this directory.
+
+`DupFind` is the GUI, and `DupFindCmd` is the CLI.
+
+### DupFind (GUI)
+
+When you launch the DupFind GUI, the following window will be displayed:
+
+![DupFind GUI](img/dupfind.png)
+
+#### Add Directory
+
+Add a directory to search. The added directories will be shown in the list. You can specify multiple directories.
+
+#### Remove Directory
+
+Remove the selected directory from the search targets. Even if removed from the list, the information within the cache is retained.
+
+#### Similarity Threshold
+
+Set the threshold for similarity when searching. A smaller value indicates higher similarity. The default is 10.
+
+#### Strict Mode
+
+Checking "Strict" will search in strict mode. Usually, you don't need to check this.
+
+#### Start Scan
+
+Starts the search. The GUI will freeze during the search, but it is calculating image hashes in the background. Once the search is complete, the results are displayed. The checkboxes on the grouped similar images indicate candidates for deletion. The checked images will be targeted for deletion. By default, smaller file sizes are marked as deletion candidates. Please uncheck them as necessary.
+
+Double-clicking a thumbnail opens the image in your OS's default viewer. Right-clicking a thumbnail opens a menu where you can copy the full path of the image to the clipboard using "Copy Full Path", or remove the item from the list using "Remove from List".
+
+#### Clear Results
+
+Clears the search results from the UI. The cache is not deleted.
+
+#### Deselect All
+
+Unchecks all checkboxes.
+
+#### Delete Selected
+
+Deletes the checked images. Deleted images are moved to the OS Trash/Recycle Bin.
+
+### DupFindCmd (CLI)
+
+`DupFindCmd` is a helper command used from the CLI. The command parameters are as follows:
+
+```console
+DupFindCmd add directory_path
+DupFindCmd th N
+DupFindCmd strict on|off
+DupFindCmd search image_file
+```
+
+#### add directory_path
+
+Adds the specified `directory_path` to the search targets and stores its information in the cache in the background. It performs the same operation as the GUI's "Start Scan" but runs in the background without displaying results. By caching directories with a large number of images using the `add` command beforehand, you can shorten the execution time of "Start Scan" in the GUI.
+
+#### th N
+
+Sets the similarity threshold to `N`. Please specify a value between 0 and 32 for `N`. The default is 10.
+
+#### strict on|off
+
+Turns strict mode on or off. The default is off.
+
+#### search image_file
+
+Searches the cache for images similar to the specified `image_file`. The threshold and strict mode used during the search rely on the current settings. The results are output to standard output. Information about `image_file` itself is not cached.
+
+## Build Instructions
+
+### Linux
+
+Building requires OpenCV, Qt6, and SQLite3. Please install these libraries and their header files, then run the following commands. For Debian/Ubuntu, install them as follows:
+
+```console
+sudo apt update
+sudo apt install build-essential cmake
+sudo apt install qt6-base-dev qt6-declarative-dev libqt6concurrent6
+sudo apt install libopencv-dev
+sudo apt install libsqlite3-dev
+sudo apt install libprotobuf-dev
+```
+
+After placing the source code in an appropriate directory, navigate to that directory and run the following commands:
+
+```console
+cmake -B build
+cmake --build build
+```
+
+### Windows
+
+Visual Studio 2026 was used to build on Windows. Please install OpenCV, Qt6, and SQLite3 using tools like vcpkg. For OpenCV and Qt, it might be easier to use their official installers instead of vcpkg. SQLite3 should be easily installable via vcpkg.
+
+Open it as a CMake project in Visual Studio 2026 to build. When using CMake, please create a `CMakePresets.json` that matches your environment. The `CMakePresets.json` attached to the source code is just a sample.
+
+### Others
+
+Since it is OS-independent, you should be able to build it on macOS or other operating systems as long as OpenCV, SQLite3, and Qt6 are installed.
+
+## Acknowledgments
+
+The following libraries were used in the development of DupFind. I am grateful for them:
 
-## 依存ツール
-- C++ コンパイラ (MSVC または GCC)
-- CMake
 - OpenCV
 - Qt 6
 - SQLite3

BIN
dupfind.png


BIN
img/dupfind.png


+ 18 - 7
include/DatabaseManager.hpp

@@ -5,35 +5,46 @@
 #include <optional>
 #include <sqlite3.h>
 
+// データベースに保存される画像情報を保持する構造体
 struct ImageData {
-    int64_t id;
-    std::string path;
-    uint64_t dhash;
-    uint64_t phash;
-    int64_t timestamp;
-    int64_t file_size;
-    bool is_searched = false;
+    int64_t id;               // DBのプライマリキー
+    std::string path;         // 画像のファイルパス
+    uint64_t dhash;           // 計算済みのdHash値
+    uint64_t phash;           // 計算済みのpHash値
+    int64_t timestamp;        // ファイルの最終更新時刻(再計算判定用)
+    int64_t file_size;        // ファイルサイズ(再計算判定用)
+    bool is_searched = false; // 検索完了ステータス(GUI等用)
 };
 
+// SQLiteを用いた画像メタデータとハッシュのDB管理クラス
 class DatabaseManager {
 public:
     DatabaseManager(const std::string& dbPath);
     ~DatabaseManager();
 
+    // データベースとの接続を開く
     bool open();
+    // データベースを閉じる
     void close();
 
+    // 画像データをDBに追加または更新する
     bool addImage(const ImageData& data);
+    // DB内の全画像情報を取得する
     std::vector<ImageData> getAllImages();
+    // 指定されたパスの画像をDBから削除する
     bool removeImage(const std::string& path);
+    // 実体ファイルが削除済みの古いエントリをDBから消去する
     void cleanupStaleEntries();
  
+    // トランザクション処理(大量追加時の高速化のため)
     void beginTransaction();
     void commitTransaction();
     void rollbackTransaction();
  
+    // 単一パスの画像情報をDBから検索して返す
     std::optional<ImageData> getImageByPath(const std::string& path);
     
+    // 指定ディレクトリ配下の全画像の検索完了状態を一括更新する
     bool setDirectorySearchedStatus(const std::string& dirPath, bool isSearched);
 
 private:

+ 7 - 4
include/ImageHasher.hpp

@@ -6,16 +6,19 @@
 
 class ImageHasher {
 public:
-    // dHash: 64-bit integer
+    // dHash(隣接ピクセルの輝度差分によるハッシュ)を計算する
+    // 戻り値: 64ビット整数値のハッシュ
     static uint64_t calculateDHash(const cv::Mat& image);
 
-    // pHash: 64-bit integer
+    // pHash(DCT: 離散コサイン変換を用いた周波数特徴ハッシュ)を計算する
+    // 戻り値: 64ビット整数値のハッシュ
     static uint64_t calculatePHash(const cv::Mat& image);
 
-    // Hamming distance
+    // 2つのハッシュ値間のハミング距離(異なるビットの数)を計算する
     static int hammingDistance(uint64_t h1, uint64_t h2);
 
-    // Helper for loading large images
+    // 大容量画像を高速に読み込むためのヘルパー関数
+    // targetSize: 縮小後の目安となる最大サイズ
     static cv::Mat loadImage(const std::string& path, int targetSize = 512);
 
 private:

+ 61 - 43
include/MainWindow.hpp

@@ -1,23 +1,25 @@
 #pragma once
 #include "DatabaseManager.hpp"
 #include "SimilaritySearch.hpp"
+#include <unordered_set>
 #include <QCheckBox>
-#include <QFutureWatcher>
-#include <QTimer>
+#include <QCloseEvent>
 #include <QEvent>
-#include <QObject>
+#include <QFutureWatcher>
 #include <QGridLayout>
 #include <QLabel>
 #include <QListWidget>
 #include <QMainWindow>
+#include <QObject>
 #include <QProgressBar>
 #include <QPushButton>
 #include <QScrollArea>
 #include <QSlider>
-#include <QCloseEvent>
+#include <QTimer>
 #include <memory>
 #include <vector>
 
+// アプリケーションのメインウィンドウ(GUI)を管理するクラス
 class MainWindow : public QMainWindow {
   Q_OBJECT
 
@@ -26,61 +28,77 @@ public:
   ~MainWindow();
 
 private slots:
-  void onAddDirectory();
-  void onRemoveDirectory();
-  void onStartScan();
-  void onDeleteSelected();
-  void onThresholdChanged(int value);
-  void onStrictChanged(int state);
-  void onScanFinished();
-  void performAsyncSearch();
-  void onSearchFinished();
-  void onClearResults();
+  // イベントに応答するスロット関数群
+  void onAddDirectory();    // 検索対象ディレクトリ追加ボタン押下
+  void onRemoveDirectory(); // リストからディレクトリ除外ボタン押下
+  void onStartScan();       // 重複検索スキャン開始ボタン押下
+  void onDeleteSelected();  // チェックされた重複画像を削除するボタン押下
+  void onThresholdChanged(int value); // 類似度しきい値スライダー変更時
+  void onStrictChanged(int state);    // Strictモードチェックボックス変更時
+  void onScanFinished();              // ディレクトリの一括スキャン完了時
+  void performAsyncSearch();          // 非同期での類似画像群の抽出実行
+  void onSearchFinished();            // 類似画像の検索完了時
+  void onClearResults();              // 結果表示のクリア
+  void removeGroupFromView(int groupId); // 指定したグループをリストから除外
 
 private:
-  void setupUi();
-  void loadSettings();
-  void saveSettings();
-  void updateResultGrid(const std::vector<DuplicateGroup> &groups);
-  std::vector<ImageData> getFilteredImages();
-  bool eventFilter(QObject *obj, QEvent *event) override;
-  void closeEvent(QCloseEvent *event) override;
+  // 内部処理・初期化メソッド
+  void setupUi();      // UIのレイアウトと初期化
+  void loadSettings(); // INIファイルからの設定値復元
+  void saveSettings(); // INIファイルへの設定値保存
+  void updateResultGrid(const std::vector<DuplicateGroup>
+                            &groups, bool preserveState = false); // 結果グリッドへサムネイルを並べる
+  std::vector<ImageData>
+  getFilteredImages(); // 現在リストにあるディレクトリの画像のみ抽出
+  bool
+  eventFilter(QObject *obj,
+              QEvent *event) override; // 右クリックメニュー等のイベントフィルタ
+  void
+  closeEvent(QCloseEvent *event) override; // ウィンドウ終了時の保存処理など
 
-  // Database
+  // データベースマネージャーのインスタンス
   std::unique_ptr<DatabaseManager> m_dbManager;
 
-  // UI Elements
-  QListWidget *m_dirList;
+  // UI要素
+  QListWidget *m_dirList; // 追加済みディレクトリのリスト
   QPushButton *m_addDirBtn;
   QPushButton *m_removeDirBtn;
   QPushButton *m_startScanBtn;
-  QProgressBar *m_progressBar;
-  QScrollArea *m_scrollArea;
+  QPushButton *m_deselectBtn;  // チェック状態を全クリアするボタン
+  QProgressBar *m_progressBar; // スキャン・検索時のプログレスバー
+  QScrollArea *m_scrollArea;   // 検索結果表示用スクロールエリア
   QWidget *m_resultWidget;
-  QGridLayout *m_resultLayout;
+  QGridLayout *m_resultLayout; // 検索結果をグリッドで配置するレイアウト
   QPushButton *m_deleteBtn;
   QPushButton *m_clearBtn;
 
-  // Similarity Controls
-  QSlider *m_thresholdSlider;
-  QLabel *m_thresholdLabel;
-  QCheckBox *m_strictCheckBox;
+  // 類似判定コントロール要素
+  QSlider *m_thresholdSlider;  // ハミング距離しきい値の設定スライダー
+  QLabel *m_thresholdLabel;    // しきい値の現在数値ラベル
+  QCheckBox *m_strictCheckBox; // pHash, dHash両方を厳密チェックするか
   int m_currentThreshold = 10;
   bool m_strictMode = false;
 
-  // Async
-  QFutureWatcher<void> *m_scanWatcher;
-  QFutureWatcher<std::vector<DuplicateGroup>> *m_searchWatcher;
-  QTimer *m_searchTimer;
- 
-  // Cache
-  std::vector<ImageData> m_lastScannedImages;
-  QStringList m_loadedDirs;
+  // 非同期(バックグラウンド)処理用オブジェクト
+  QFutureWatcher<void> *m_scanWatcher; // 画像スキャンの進捗監視
+  QFutureWatcher<std::vector<DuplicateGroup>>
+      *m_searchWatcher; // 類似検索の進捗監視
+  QTimer
+      *m_searchTimer; // 頻繁なスライダ操作をまとめる(デバウンス)ためのタイマ
+
+  // キャッシュ
+  std::vector<ImageData>
+      m_lastScannedImages;  // 各ディレクトリから抽出した画像キャッシュ
+  QStringList m_loadedDirs; // 起動時にロードされたディレクトリ群
 
-  // Checkboxes for tracking selected images
+  // 結果画面に追加された画像とチェックボックスを管理する構造体
   struct ResultItem {
-    QCheckBox *checkbox;
-    std::string path;
+    QCheckBox *checkbox; // 削除対象としてマークするチェックボックス
+    std::string path;    // チェックボックスに紐づく画像パス
+    int groupId;         // 属する重複グループのID(全削除警告用)
   };
-  std::vector<ResultItem> m_resultItems;
+  std::vector<ResultItem>
+      m_resultItems; // 現在表示されている結果アイテムのリスト
+  std::vector<DuplicateGroup> m_currentGroups; // 現在表示中のグループ一覧
+  std::unordered_set<std::string> m_ignoredPaths; // セッション中に除外した画像のパス
 };

+ 6 - 1
include/SimilaritySearch.hpp

@@ -3,14 +3,19 @@
 #include <vector>
 #include <map>
 
+// 重複画像と判定された画像のグループをまとめる構造体
 struct DuplicateGroup {
     std::vector<ImageData> images;
 };
 
+// 類似画像(重複)検索を処理するクラス
 class SimilaritySearch {
 public:
+    // 指定された画像リストから重複画像のグループを抽出する
+    // threshold: ハミング距離の許容閾値(これ以下なら類似とみなす)
+    // strict: trueならdHashとpHashの両方、falseなら片方が閾値以下であれば類似と判定
     static std::vector<DuplicateGroup> findDuplicates(const std::vector<ImageData>& images, int threshold = 5, bool strict = false);
 
 private:
-    // Simple clustering algorithm
+    // 内部で使用されるシンプルなクラスタリングアルゴリズム
 };

+ 207 - 176
src/CmdMain.cpp

@@ -1,213 +1,244 @@
 #include <QCoreApplication>
-#include <QSettings>
-#include <QStringList>
+#include <QDirIterator>
 #include <QProcess>
+#include <QSettings>
 #include <QSharedMemory>
+#include <QStringList>
 #include <QThread>
-#include <QDirIterator>
-#include <iostream>
-#include <filesystem>
 #include <chrono>
+#include <filesystem>
+#include <iostream>
 #include <unordered_map>
 
 #include "DatabaseManager.hpp"
 #include "ImageHasher.hpp"
 #include <opencv2/core/utils/logger.hpp>
 
-void workerAdd(const QStringList& dirs);
-void cmdAdd(const QStringList& dirs);
+void workerAdd(const QStringList &dirs);
+void cmdAdd(const QStringList &dirs);
 void cmdTh(int thValue);
-void cmdSearch(const QString& imageFile);
+void cmdSearch(const QString &imageFile);
+void cmdStrict(bool strict);
 
 int main(int argc, char *argv[]) {
-    // OpenCVの不要なINFOログ(並列バックエンド読み込み失敗など)を抑制する
-    cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_ERROR);
-
-    QCoreApplication app(argc, argv);
-    QStringList args = app.arguments();
-
-    if (args.size() < 2) {
-        std::cerr << "Usage: DupFindCmd add <dir1> <dir2> ...\n"
-                  << "       DupFindCmd th <N>\n"
-                  << "       DupFindCmd search <image_file>\n";
-        return 1;
+  // OpenCVの不要なINFOログ(並列バックエンド読み込み失敗など)を抑制する
+  cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_ERROR);
+
+  QCoreApplication app(argc, argv);
+  QStringList args = app.arguments();
+
+  if (args.size() < 2) {
+    std::cerr << "Usage: DupFindCmd add <dir1> <dir2> ...\n"
+              << "       DupFindCmd th <N>\n"
+              << "       DupFindCmd search <image_file>\n"
+              << "       DupFindCmd strict [on|off]\n";
+    return 1;
+  }
+
+  QString command = args[1];
+
+  if (command == "--worker-add") {
+    QStringList dirs = args.mid(2);
+    workerAdd(dirs);
+    return 0;
+  } else if (command == "add") {
+    if (args.size() < 3) {
+      std::cerr << "Error: 'add' Requires at least one directory.\n";
+      return 1;
     }
-
-    QString command = args[1];
-
-    if (command == "--worker-add") {
-        QStringList dirs = args.mid(2);
-        workerAdd(dirs);
-        return 0;
-    } else if (command == "add") {
-        if (args.size() < 3) {
-            std::cerr << "Error: 'add' Requires at least one directory.\n";
-            return 1;
-        }
-        cmdAdd(args.mid(2));
-        return 0;
-    } else if (command == "th") {
-        if (args.size() != 3) {
-            std::cerr << "Error: 'th' Requires exactly one integer.\n";
-            return 1;
-        }
-        bool ok;
-        int val = args[2].toInt(&ok);
-        if (!ok || val < 0 || val > 32) {
-            std::cerr << "Error: N must be an integer between 0 and 32.\n";
-            return 1;
-        }
-        cmdTh(val);
-        return 0;
-    } else if (command == "search") {
-        if (args.size() != 3) {
-            std::cerr << "Error: 'search' Requires exactly one image file.\n";
-            return 1;
-        }
-        cmdSearch(args[2]);
-        return 0;
-    } else {
-        std::cerr << "Unknown command: " << command.toStdString() << "\n";
-        return 1;
+    cmdAdd(args.mid(2));
+    return 0;
+  } else if (command == "th") {
+    if (args.size() != 3) {
+      std::cerr << "Error: 'th' Requires exactly one integer.\n";
+      return 1;
+    }
+    bool ok;
+    int val = args[2].toInt(&ok);
+    if (!ok || val < 0 || val > 32) {
+      std::cerr << "Error: N must be an integer between 0 and 32.\n";
+      return 1;
+    }
+    cmdTh(val);
+    return 0;
+  } else if (command == "search") {
+    if (args.size() != 3) {
+      std::cerr << "Error: 'search' Requires exactly one image file.\n";
+      return 1;
+    }
+    cmdSearch(args[2]);
+    return 0;
+  } else if (command == "strict") {
+    if (args.size() != 3) {
+      std::cerr << "Error: 'strict' Requires exactly one argument.\n";
+      return 1;
     }
+    bool val = (args[2] == "on");
+    cmdStrict(val);
+    return 0;
+  } else {
+    std::cerr << "Unknown command: " << command.toStdString() << "\n";
+    return 1;
+  }
 }
 
 void cmdTh(int thValue) {
-    QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
-    QSettings settings(iniPath, QSettings::IniFormat);
-    settings.setValue("threshold", thValue);
-    std::cout << "Successfully updated threshold to " << thValue << ".\n";
+  QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
+  QSettings settings(iniPath, QSettings::IniFormat);
+  settings.setValue("threshold", thValue);
+  std::cout << "Successfully updated threshold to " << thValue << ".\n";
 }
 
-void cmdAdd(const QStringList& dirs) {
-    QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
-    QSettings settings(iniPath, QSettings::IniFormat);
+// GUI以外からディレクトリ追加を指示するコマンド
+// 設定ファイル(INI)を更新した上で、バックグラウンドのワーカプロセスをデタッチ起動する
+void cmdAdd(const QStringList &dirs) {
+  QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
+  QSettings settings(iniPath, QSettings::IniFormat);
 
-    QStringList existingDirs = settings.value("directories").toStringList();
-    bool changed = false;
+  QStringList existingDirs = settings.value("directories").toStringList();
+  bool changed = false;
 
-    for (const QString& dir : dirs) {
-        if (!existingDirs.contains(dir, Qt::CaseInsensitive)) { // 念のためWindowsなど考慮
-            existingDirs.append(dir);
-            changed = true;
-        }
+  for (const QString &dir : dirs) {
+    if (!existingDirs.contains(
+            dir, Qt::CaseInsensitive)) { // 念のためWindowsなど考慮
+      existingDirs.append(dir);
+      changed = true;
     }
+  }
 
-    if (changed) {
-        settings.setValue("directories", existingDirs);
-    }
+  if (changed) {
+    settings.setValue("directories", existingDirs);
+  }
 
-    std::cout << "Started background scan.\n";
+  std::cout << "Started background scan.\n";
 
-    // デタッチしてバックグラウンドプロセスを起動
-    QString program = QCoreApplication::applicationFilePath();
-    QStringList workerArgs;
-    workerArgs << "--worker-add" << dirs;
-    
-    QProcess::startDetached(program, workerArgs);
-}
+  // デタッチしてバックグラウンドプロセスを起動
+  QString program = QCoreApplication::applicationFilePath();
+  QStringList workerArgs;
+  workerArgs << "--worker-add" << dirs;
 
-void workerAdd(const QStringList& dirs) {
-    // 優先度を下げる
-    QThread::currentThread()->setPriority(QThread::IdlePriority);
-    
-    // GUIが起動しているか確認するための共有メモリ
-    QSharedMemory sharedMem("DupFind_GUI_Instance");
-    
-    DatabaseManager dbManager("dupfind_cache.db");
-    if (!dbManager.open()) return;
-
-    auto cachedList = dbManager.getAllImages();
-    std::unordered_map<std::string, ImageData> cache;
-    for (const auto& img : cachedList) {
-        cache[img.path] = img;
-    }
+  QProcess::startDetached(program, workerArgs);
+}
 
-    const QStringList filters = { "*.jpg", "*.png", "*.jpeg", "*.bmp" };
-    int count = 0;
-    
-    for (const QString& dirPath : dirs) {
-        QDirIterator it(dirPath, filters, QDir::Files | QDir::NoSymLinks, QDirIterator::Subdirectories);
-        
-        while (it.hasNext()) {
-            std::string stdPath = it.next().toStdString();
-            
-            // 少し粒度を荒くしてGUI起動チェック (10ファイルごと)
-            if (count % 10 == 0) {
-                if (sharedMem.attach()) {
-                    // GUIが共有メモリを確保している=起動中
-                    sharedMem.detach();
-                    return; // 即座に終了
-                }
-            }
-            count++;
-
-            try {
-                std::error_code ec;
-                auto currentSize = std::filesystem::file_size(stdPath, ec);
-                auto currentMtime = std::chrono::duration_cast<std::chrono::seconds>(
-                    std::filesystem::last_write_time(stdPath, ec).time_since_epoch()).count();
-
-                auto cacheIt = cache.find(stdPath);
-                if (cacheIt != cache.end()) {
-                    if (cacheIt->second.file_size == static_cast<int64_t>(currentSize) && 
-                        cacheIt->second.timestamp == static_cast<int64_t>(currentMtime)) {
-                        continue; // すでに最新ハッシュ計算済み
-                    }
-                }
-
-                cv::Mat img = ImageHasher::loadImage(stdPath);
-                if (img.empty()) continue;
-
-                ImageData data;
-                data.path = stdPath;
-                data.dhash = ImageHasher::calculateDHash(img);
-                data.phash = ImageHasher::calculatePHash(img);
-                data.timestamp = currentMtime;
-                data.file_size = currentSize;
-                data.is_searched = false;
-
-                dbManager.addImage(data);
-
-            } catch (...) {
-                // ファイルIOエラー等はスキップ
-            }
+// バックグラウンドで画像ファイルのハッシュ値(dHash, pHash)を計算し、DBへ書き込む処理
+// GUI動作との競合を避ける配慮などが実装されている
+void workerAdd(const QStringList &dirs) {
+  // バックグラウンド処理のためCPU優先度を下げる
+  QThread::currentThread()->setPriority(QThread::IdlePriority);
+
+  // GUIが起動しているか確認するための共有メモリ
+  QSharedMemory sharedMem("DupFind_GUI_Instance");
+
+  DatabaseManager dbManager("dupfind_cache.db");
+  if (!dbManager.open())
+    return;
+
+  auto cachedList = dbManager.getAllImages();
+  std::unordered_map<std::string, ImageData> cache;
+  for (const auto &img : cachedList) {
+    cache[img.path] = img;
+  }
+
+  const QStringList filters = {"*.jpg",  "*.png",  "*.jpeg", "*.bmp",
+                               "*.webp", "*.tiff", "*.heic", "*.heif"};
+  int count = 0;
+
+  for (const QString &dirPath : dirs) {
+    QDirIterator it(dirPath, filters, QDir::Files | QDir::NoSymLinks,
+                    QDirIterator::Subdirectories);
+
+    while (it.hasNext()) {
+      std::string stdPath = it.next().toStdString();
+
+      // 少し粒度を荒くしてGUI起動チェック (10ファイルごと)
+      if (count % 10 == 0) {
+        if (sharedMem.attach()) {
+          // GUIが共有メモリを確保している=起動中
+          sharedMem.detach();
+          return; // 即座に終了
         }
+      }
+      count++;
+
+      try {
+        std::error_code ec;
+        auto currentSize = std::filesystem::file_size(stdPath, ec);
+        auto currentMtime = std::chrono::duration_cast<std::chrono::seconds>(
+                                std::filesystem::last_write_time(stdPath, ec)
+                                    .time_since_epoch())
+                                .count();
+
+        auto cacheIt = cache.find(stdPath);
+        if (cacheIt != cache.end()) {
+          if (cacheIt->second.file_size == static_cast<int64_t>(currentSize) &&
+              cacheIt->second.timestamp == static_cast<int64_t>(currentMtime)) {
+            continue; // すでに最新ハッシュ計算済み
+          }
+        }
+
+        cv::Mat img = ImageHasher::loadImage(stdPath);
+        if (img.empty())
+          continue;
+
+        ImageData data;
+        data.path = stdPath;
+        data.dhash = ImageHasher::calculateDHash(img);
+        data.phash = ImageHasher::calculatePHash(img);
+        data.timestamp = currentMtime;
+        data.file_size = currentSize;
+        data.is_searched = false;
+
+        dbManager.addImage(data);
+
+      } catch (...) {
+        // ファイルIOエラー等はスキップ
+      }
     }
+  }
 }
 
-void cmdSearch(const QString& imageFile) {
-    std::string stdPath = imageFile.toStdString();
-    if (!std::filesystem::exists(stdPath)) {
-        return;
-    }
+// 指定された1つの画像ファイルと類似する「DB上の全ての画像」を検索してパスを出力するコマンド
+void cmdSearch(const QString &imageFile) {
+  std::string stdPath = imageFile.toStdString();
+  if (!std::filesystem::exists(stdPath)) {
+    return;
+  }
 
-    cv::Mat img = ImageHasher::loadImage(stdPath);
-    if (img.empty()) {
-        return;
-    }
+  cv::Mat img = ImageHasher::loadImage(stdPath);
+  if (img.empty()) {
+    return;
+  }
 
-    uint64_t dhash = ImageHasher::calculateDHash(img);
-    uint64_t phash = ImageHasher::calculatePHash(img);
-
-    QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
-    QSettings settings(iniPath, QSettings::IniFormat);
-    int threshold = settings.value("threshold", 10).toInt();
-    bool strict = settings.value("strict_mode", false).toBool();
-
-    DatabaseManager dbManager("dupfind_cache.db");
-    if (!dbManager.open()) return;
-
-    auto allImages = dbManager.getAllImages();
-    for (const auto& imgData : allImages) {
-        int distD = ImageHasher::hammingDistance(dhash, imgData.dhash);
-        int distP = ImageHasher::hammingDistance(phash, imgData.phash);
-        
-        bool similar = strict ? (distD <= threshold && distP <= threshold) 
-                              : (distD <= threshold || distP <= threshold);
-                              
-        if (similar) {
-            std::cout << imgData.path << "\n";
-        }
+  uint64_t dhash = ImageHasher::calculateDHash(img);
+  uint64_t phash = ImageHasher::calculatePHash(img);
+
+  QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
+  QSettings settings(iniPath, QSettings::IniFormat);
+  int threshold = settings.value("threshold", 10).toInt();
+  bool strict = settings.value("strict_mode", false).toBool();
+
+  DatabaseManager dbManager("dupfind_cache.db");
+  if (!dbManager.open())
+    return;
+
+  auto allImages = dbManager.getAllImages();
+  for (const auto &imgData : allImages) {
+    int distD = ImageHasher::hammingDistance(dhash, imgData.dhash);
+    int distP = ImageHasher::hammingDistance(phash, imgData.phash);
+
+    bool similar = strict ? (distD <= threshold && distP <= threshold)
+                          : (distD <= threshold || distP <= threshold);
+
+    if (similar) {
+      std::cout << imgData.path << "\n";
     }
+  }
+}
+
+void cmdStrict(bool strict) {
+  QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
+  QSettings settings(iniPath, QSettings::IniFormat);
+  settings.setValue("strict_mode", strict);
+  std::cout << "Successfully updated strict mode to " << (strict ? "on" : "off")
+            << ".\n";
 }

+ 4 - 0
src/DatabaseManager.cpp

@@ -30,6 +30,8 @@ void DatabaseManager::close() {
     }
 }
 
+// データベースのテーブル構成(スキーマ)を初期化する
+// 初回起動時ならテーブルを作成、既存ならカラム追加などのマイグレーションを行う
 bool DatabaseManager::initSchema() {
     const char* sql = "CREATE TABLE IF NOT EXISTS images ("
                       "id INTEGER PRIMARY KEY AUTOINCREMENT,"
@@ -51,6 +53,7 @@ bool DatabaseManager::initSchema() {
     return true;
 }
 
+// 既存パスなら上書き(REPLACE)、新規なら挿入(INSERT)でDBに画像データを追加する
 bool DatabaseManager::addImage(const ImageData& data) {
     const char* sql = "INSERT OR REPLACE INTO images (path, dhash, phash, timestamp, file_size, is_searched) VALUES (?, ?, ?, ?, ?, ?);";
     sqlite3_stmt* stmt;
@@ -101,6 +104,7 @@ bool DatabaseManager::removeImage(const std::string& path) {
 }
  
 #include <filesystem>
+// DBにはあるがディスク上に実ファイルが存在しない「古いゴミデータ」を削除しクリーンナップする
 void DatabaseManager::cleanupStaleEntries() {
     auto images = getAllImages();
     beginTransaction();

+ 5 - 0
src/ImageHasher.cpp

@@ -2,6 +2,8 @@
 #include <opencv2/imgproc.hpp>
 #include <bit> // C++20 std::popcount
 
+// 画像の輝度勾配(隣り合うピクセルの明暗)からハッシュを計算する(Difference Hash)
+// 処理が軽く、単純なリサイズ等に強い特徴がある
 uint64_t ImageHasher::calculateDHash(const cv::Mat& image) {
     if (image.empty()) return 0;
 
@@ -21,6 +23,8 @@ uint64_t ImageHasher::calculateDHash(const cv::Mat& image) {
     return hash;
 }
 
+// 画像の低周波成分(全体的な形状やぼんやりとした特徴)からハッシュを計算する(Perceptual Hash)
+// 多少の色調変化やノイズに対してよりロバスト(堅牢)な特徴がある
 uint64_t ImageHasher::calculatePHash(const cv::Mat& image) {
     if (image.empty()) return 0;
 
@@ -54,6 +58,7 @@ uint64_t ImageHasher::calculatePHash(const cv::Mat& image) {
     return hash;
 }
 
+// ハミング距離を計算する (std::popcount でハードウェア命令を使い高速化)
 int ImageHasher::hammingDistance(uint64_t h1, uint64_t h2) {
     return static_cast<int>(std::popcount(h1 ^ h2));
 }

+ 551 - 401
src/MainWindow.cpp

@@ -1,477 +1,627 @@
 #include "MainWindow.hpp"
 #include "ImageHasher.hpp"
-#include <QVBoxLayout>
-#include <QHBoxLayout>
-#include <QFileDialog>
-#include <QMessageBox>
 #include <QDirIterator>
 #include <QFile>
+#include <QFileDialog>
+#include <QHBoxLayout>
+#include <QMessageBox>
 #include <QPixmap>
+#include <QVBoxLayout>
 #include <chrono>
 #include <filesystem>
 
-#include <QtConcurrent>
-#include <QDesktopServices>
-#include <QUrl>
-#include <QSettings>
+#include <QAction>
+#include <QApplication>
+#include <QClipboard>
+#include <QContextMenuEvent>
 #include <QCoreApplication>
+#include <QDesktopServices>
 #include <QImageReader>
- 
- 
-
-MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
-    m_dbManager = std::make_unique<DatabaseManager>("dupfind_cache.db");
-    m_dbManager->open();
-    m_scanWatcher = new QFutureWatcher<void>(this);
-    m_searchWatcher = new QFutureWatcher<std::vector<DuplicateGroup>>(this);
-    
-    m_searchTimer = new QTimer(this);
-    m_searchTimer->setSingleShot(true);
-    m_searchTimer->setInterval(300);
-    connect(m_searchTimer, &QTimer::timeout, this, &MainWindow::performAsyncSearch);
-    connect(m_searchWatcher, &QFutureWatcher<std::vector<DuplicateGroup>>::finished, this, &MainWindow::onSearchFinished);
-    
-    
-    // スタイルの適用
-    QFile styleFile("resources/style.qss");
-    if (styleFile.open(QFile::ReadOnly)) {
-        QString styleSheet = QLatin1String(styleFile.readAll());
-        setStyleSheet(styleSheet);
-    }
-    
-    loadSettings(); // setupUi の前に読み込む
-    setupUi();
+#include <QMenu>
+#include <QSettings>
+#include <QUrl>
+#include <QtConcurrent>
+
+MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
+  m_dbManager = std::make_unique<DatabaseManager>("dupfind_cache.db");
+  m_dbManager->open();
+  m_scanWatcher = new QFutureWatcher<void>(this);
+  m_searchWatcher = new QFutureWatcher<std::vector<DuplicateGroup>>(this);
+
+  m_searchTimer = new QTimer(this);
+  m_searchTimer->setSingleShot(true);
+  m_searchTimer->setInterval(300);
+  connect(m_searchTimer, &QTimer::timeout, this,
+          &MainWindow::performAsyncSearch);
+  connect(m_searchWatcher,
+          &QFutureWatcher<std::vector<DuplicateGroup>>::finished, this,
+          &MainWindow::onSearchFinished);
+
+  // スタイルの適用
+  QFile styleFile("resources/style.qss");
+  if (styleFile.open(QFile::ReadOnly)) {
+    QString styleSheet = QLatin1String(styleFile.readAll());
+    setStyleSheet(styleSheet);
+  }
+
+  loadSettings(); // setupUi の前に読み込む
+  setupUi();
 }
 
 MainWindow::~MainWindow() {}
 
 std::vector<ImageData> MainWindow::getFilteredImages() {
-    auto allImages = m_dbManager->getAllImages();
-    if (m_dirList->count() == 0) return {};
-    
-    std::vector<ImageData> filtered;
-    for (const auto& img : allImages) {
-        bool match = false;
-        for (int i = 0; i < m_dirList->count(); ++i) {
-            std::string dirPath = m_dirList->item(i)->text().toStdString();
-            std::string dirPathWithSlash = dirPath;
-            if (!dirPathWithSlash.empty() && dirPathWithSlash.back() != '/' && dirPathWithSlash.back() != '\\') {
-                dirPathWithSlash += "/";
-            }
-            if (img.path.find(dirPathWithSlash) == 0 || img.path == dirPath) {
-                match = true;
-                break;
-            }
-        }
-        if (match) {
-            filtered.push_back(img);
-        }
+  auto allImages = m_dbManager->getAllImages();
+  if (m_dirList->count() == 0)
+    return {};
+
+  std::vector<ImageData> filtered;
+  for (const auto &img : allImages) {
+    bool match = false;
+    for (int i = 0; i < m_dirList->count(); ++i) {
+      std::string dirPath = m_dirList->item(i)->text().toStdString();
+      std::string dirPathWithSlash = dirPath;
+      if (!dirPathWithSlash.empty() && dirPathWithSlash.back() != '/' &&
+          dirPathWithSlash.back() != '\\') {
+        dirPathWithSlash += "/";
+      }
+      if (img.path.find(dirPathWithSlash) == 0 || img.path == dirPath) {
+        match = true;
+        break;
+      }
+    }
+    if (match && m_ignoredPaths.find(img.path) == m_ignoredPaths.end()) {
+      filtered.push_back(img);
     }
-    return filtered;
+  }
+  return filtered;
 }
 
 void MainWindow::setupUi() {
-    auto* central = new QWidget();
-    auto* mainLayout = new QVBoxLayout(central);
-
-    // Toolbar-like top section
-    auto* toolLayout = new QHBoxLayout();
-    m_addDirBtn = new QPushButton("Add Directory");
-    m_removeDirBtn = new QPushButton("Remove Selected");
-    m_startScanBtn = new QPushButton("Start Scan");
-    m_deleteBtn = new QPushButton("Delete Selected Duplicates");
-    m_deleteBtn->setObjectName("deleteBtn"); // QSS で赤くするため
-    
-    toolLayout->addWidget(m_addDirBtn);
-    toolLayout->addWidget(m_removeDirBtn);
-    toolLayout->addStretch();
- 
-    // Slider section
-    toolLayout->addWidget(new QLabel("Similarity Threshold:"));
-    m_thresholdSlider = new QSlider(Qt::Horizontal);
-    m_thresholdSlider->setRange(0, 32);
-    m_thresholdSlider->setValue(m_currentThreshold);
-    m_thresholdSlider->setFixedWidth(150);
-    m_thresholdLabel = new QLabel(QString::number(m_currentThreshold));
-    toolLayout->addWidget(m_thresholdSlider);
-    toolLayout->addWidget(m_thresholdLabel);
-
-    m_strictCheckBox = new QCheckBox("Strict");
-    m_strictCheckBox->setChecked(m_strictMode);
-    toolLayout->addWidget(m_strictCheckBox);
- 
-    toolLayout->addWidget(m_startScanBtn);
-    m_clearBtn = new QPushButton("Clear Results");
-    toolLayout->addWidget(m_clearBtn);
-    toolLayout->addWidget(m_deleteBtn);
-    mainLayout->addLayout(toolLayout);
-
-    // Split view
-    auto* splitLayout = new QHBoxLayout();
-    
-    // Directory List
-    m_dirList = new QListWidget();
-    m_dirList->setMaximumWidth(250);
-    m_dirList->addItems(m_loadedDirs); // ここで復元
-    splitLayout->addWidget(m_dirList);
-
-    // Results Scroll Area
-    m_scrollArea = new QScrollArea();
-    m_scrollArea->setWidgetResizable(true);
-    m_resultWidget = new QWidget();
-    m_resultLayout = new QGridLayout(m_resultWidget);
-    m_scrollArea->setWidget(m_resultWidget);
-    splitLayout->addWidget(m_scrollArea);
-
-    mainLayout->addLayout(splitLayout);
-
-    // Progress Bar
-    m_progressBar = new QProgressBar();
-    m_progressBar->setVisible(false);
-    mainLayout->addWidget(m_progressBar);
-
-    setCentralWidget(central);
-
-    // Connections
-    connect(m_addDirBtn, &QPushButton::clicked, this, &MainWindow::onAddDirectory);
-    connect(m_removeDirBtn, &QPushButton::clicked, this, &MainWindow::onRemoveDirectory);
-    connect(m_startScanBtn, &QPushButton::clicked, this, &MainWindow::onStartScan);
-    connect(m_clearBtn, &QPushButton::clicked, this, &MainWindow::onClearResults);
-    connect(m_deleteBtn, &QPushButton::clicked, this, &MainWindow::onDeleteSelected);
-    connect(m_thresholdSlider, &QSlider::valueChanged, this, &MainWindow::onThresholdChanged);
-    connect(m_strictCheckBox, &QCheckBox::stateChanged, this, &MainWindow::onStrictChanged);
-    connect(m_scanWatcher, &QFutureWatcher<void>::finished, this, &MainWindow::onScanFinished);
+  auto *central = new QWidget();
+  auto *mainLayout = new QVBoxLayout(central);
+
+  // Toolbar-like top section
+  auto *toolLayout = new QHBoxLayout();
+  m_addDirBtn = new QPushButton("Add Directory");
+  m_removeDirBtn = new QPushButton("Remove Selected");
+  m_startScanBtn = new QPushButton("Start Scan");
+  m_deleteBtn = new QPushButton("Delete Selected Duplicates");
+  m_deleteBtn->setObjectName("deleteBtn"); // QSS で赤くするため
+
+  toolLayout->addWidget(m_addDirBtn);
+  toolLayout->addWidget(m_removeDirBtn);
+  toolLayout->addStretch();
+
+  // Slider section
+  toolLayout->addWidget(new QLabel("Similarity Threshold:"));
+  m_thresholdSlider = new QSlider(Qt::Horizontal);
+  m_thresholdSlider->setRange(0, 32);
+  m_thresholdSlider->setValue(m_currentThreshold);
+  m_thresholdSlider->setFixedWidth(150);
+  m_thresholdLabel = new QLabel(QString::number(m_currentThreshold));
+  toolLayout->addWidget(m_thresholdSlider);
+  toolLayout->addWidget(m_thresholdLabel);
+
+  m_strictCheckBox = new QCheckBox("Strict");
+  m_strictCheckBox->setChecked(m_strictMode);
+  toolLayout->addWidget(m_strictCheckBox);
+
+  toolLayout->addWidget(m_startScanBtn);
+  m_clearBtn = new QPushButton("Clear Results");
+  toolLayout->addWidget(m_clearBtn);
+
+  m_deselectBtn = new QPushButton("Deselect All");
+  toolLayout->addWidget(m_deselectBtn);
+
+  toolLayout->addWidget(m_deleteBtn);
+  mainLayout->addLayout(toolLayout);
+
+  // Split view
+  auto *splitLayout = new QHBoxLayout();
+
+  // Directory List
+  m_dirList = new QListWidget();
+  m_dirList->setMaximumWidth(250);
+  m_dirList->addItems(m_loadedDirs); // ここで復元
+  splitLayout->addWidget(m_dirList);
+
+  // Results Scroll Area
+  m_scrollArea = new QScrollArea();
+  m_scrollArea->setWidgetResizable(true);
+  m_resultWidget = new QWidget();
+  m_resultLayout = new QGridLayout(m_resultWidget);
+  m_scrollArea->setWidget(m_resultWidget);
+  splitLayout->addWidget(m_scrollArea);
+
+  mainLayout->addLayout(splitLayout);
+
+  // Progress Bar
+  m_progressBar = new QProgressBar();
+  m_progressBar->setVisible(false);
+  mainLayout->addWidget(m_progressBar);
+
+  setCentralWidget(central);
+
+  // Connections
+  connect(m_addDirBtn, &QPushButton::clicked, this,
+          &MainWindow::onAddDirectory);
+  connect(m_removeDirBtn, &QPushButton::clicked, this,
+          &MainWindow::onRemoveDirectory);
+  connect(m_startScanBtn, &QPushButton::clicked, this,
+          &MainWindow::onStartScan);
+  connect(m_clearBtn, &QPushButton::clicked, this, &MainWindow::onClearResults);
+
+  connect(m_deselectBtn, &QPushButton::clicked, this, [this]() {
+    for (auto &item : m_resultItems) {
+      if (item.checkbox)
+        item.checkbox->setChecked(false);
+    }
+  });
+
+  connect(m_deleteBtn, &QPushButton::clicked, this,
+          &MainWindow::onDeleteSelected);
+  connect(m_thresholdSlider, &QSlider::valueChanged, this,
+          &MainWindow::onThresholdChanged);
+  connect(m_strictCheckBox, &QCheckBox::stateChanged, this,
+          &MainWindow::onStrictChanged);
+  connect(m_scanWatcher, &QFutureWatcher<void>::finished, this,
+          &MainWindow::onScanFinished);
 }
 
 void MainWindow::loadSettings() {
-    QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
-    QSettings settings(iniPath, QSettings::IniFormat);
-    
-    m_currentThreshold = settings.value("threshold", 10).toInt();
-    m_strictMode = settings.value("strict_mode", false).toBool();
-    m_loadedDirs = settings.value("directories").toStringList();
+  QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
+  QSettings settings(iniPath, QSettings::IniFormat);
+
+  m_currentThreshold = settings.value("threshold", 10).toInt();
+  m_strictMode = settings.value("strict_mode", false).toBool();
+  m_loadedDirs = settings.value("directories").toStringList();
 }
 
 void MainWindow::saveSettings() {
-    QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
-    QSettings settings(iniPath, QSettings::IniFormat);
-    
-    settings.setValue("threshold", m_currentThreshold);
-    settings.setValue("strict_mode", m_strictMode);
-    
-    QStringList dirs;
-    for (int i = 0; i < m_dirList->count(); ++i) {
-        dirs << m_dirList->item(i)->text();
-    }
-    settings.setValue("directories", dirs);
+  QString iniPath = QCoreApplication::applicationDirPath() + "/DupFind.ini";
+  QSettings settings(iniPath, QSettings::IniFormat);
+
+  settings.setValue("threshold", m_currentThreshold);
+  settings.setValue("strict_mode", m_strictMode);
+
+  QStringList dirs;
+  for (int i = 0; i < m_dirList->count(); ++i) {
+    dirs << m_dirList->item(i)->text();
+  }
+  settings.setValue("directories", dirs);
 }
 
-void MainWindow::closeEvent(QCloseEvent* event) {
-    saveSettings();
-    QMainWindow::closeEvent(event);
+void MainWindow::closeEvent(QCloseEvent *event) {
+  saveSettings();
+  QMainWindow::closeEvent(event);
 }
 
 void MainWindow::onAddDirectory() {
-    QString dir = QFileDialog::getExistingDirectory(this, "Select Directory to Scan");
-    if (!dir.isEmpty()) {
-        m_dirList->addItem(dir);
-    }
+  QString dir =
+      QFileDialog::getExistingDirectory(this, "Select Directory to Scan");
+  if (!dir.isEmpty()) {
+    m_dirList->addItem(dir);
+  }
 }
 
 void MainWindow::onRemoveDirectory() {
-    auto* item = m_dirList->currentItem();
-    if (item) {
-        QString dirPath = item->text();
-        m_dbManager->setDirectorySearchedStatus(dirPath.toStdString(), false);
-        delete item;
-        // キャッシュ再読み込み
-        m_lastScannedImages = getFilteredImages();
-    }
+  auto *item = m_dirList->currentItem();
+  if (item) {
+    QString dirPath = item->text();
+    m_dbManager->setDirectorySearchedStatus(dirPath.toStdString(), false);
+    delete item;
+    // キャッシュ再読み込み
+    m_lastScannedImages = getFilteredImages();
+  }
 }
 
+// 「Start Scan」ボタン押下時の処理
+// 選択されたディレクトリを再帰的に走査し、並列処理によって高速に画像ハッシュを分散計算し、DB保存する
 void MainWindow::onStartScan() {
-    if (m_dirList->count() == 0) {
-        QMessageBox::warning(this, "No Directory", "Please add at least one directory to scan.");
-        return;
+  if (m_dirList->count() == 0) {
+    QMessageBox::warning(this, "No Directory",
+                         "Please add at least one directory to scan.");
+    return;
+  }
+
+  m_progressBar->setVisible(true);
+  m_progressBar->setRange(0, 0); // 準備中
+  m_startScanBtn->setEnabled(false);
+
+  // 1. スキャン対象ディレクトリの収集
+  std::vector<QString> dirPaths;
+  for (int i = 0; i < m_dirList->count(); ++i) {
+    dirPaths.push_back(m_dirList->item(i)->text());
+  }
+
+  // キャッシュ(DBの既存データ)を読み込み
+  auto cachedList = m_dbManager->getAllImages();
+  std::unordered_map<std::string, ImageData> cache;
+  for (const auto &img : cachedList) {
+    cache[img.path] = img;
+  }
+
+  // 2. 非同期で一連の処理を実行
+  auto future = QtConcurrent::run([this, dirPaths, cache]() {
+    // 全ファイルパスをリストアップ
+    std::vector<std::string> allFiles;
+    const QStringList filters = {"*.jpg",  "*.png",  "*.jpeg", "*.bmp",
+                                 "*.webp", "*.tiff", "*.heic", "*.heif"};
+    for (const auto &dirPath : dirPaths) {
+      QDirIterator it(dirPath, filters, QDir::Files | QDir::NoSymLinks,
+                      QDirIterator::Subdirectories);
+      while (it.hasNext()) {
+        allFiles.push_back(it.next().toStdString());
+      }
     }
- 
-    m_progressBar->setVisible(true);
-    m_progressBar->setRange(0, 0); // 準備中
-    m_startScanBtn->setEnabled(false);
- 
-    // 1. スキャン対象ディレクトリの収集
-    std::vector<QString> dirPaths;
-    for (int i = 0; i < m_dirList->count(); ++i) {
-        dirPaths.push_back(m_dirList->item(i)->text());
-    }
- 
-    // キャッシュ(DBの既存データ)を読み込み
-    auto cachedList = m_dbManager->getAllImages();
-    std::unordered_map<std::string, ImageData> cache;
-    for (const auto& img : cachedList) {
-        cache[img.path] = img;
-    }
- 
-    // 2. 非同期で一連の処理を実行
-    auto future = QtConcurrent::run([this, dirPaths, cache]() {
-        // 全ファイルパスをリストアップ
-        std::vector<std::string> allFiles;
-        const QStringList filters = { "*.jpg", "*.png", "*.jpeg", "*.bmp" };
-        for (const auto& dirPath : dirPaths) {
-            QDirIterator it(dirPath, filters, QDir::Files | QDir::NoSymLinks, QDirIterator::Subdirectories);
-            while (it.hasNext()) {
-                allFiles.push_back(it.next().toStdString());
-            }
+
+    // 3. 並列ハッシュ計算 (mapped)
+    auto processFunc = [&cache](const std::string &stdPath) -> ImageData {
+      try {
+        std::error_code ec;
+        auto currentSize = std::filesystem::file_size(stdPath, ec);
+        auto currentMtime = std::chrono::duration_cast<std::chrono::seconds>(
+                                std::filesystem::last_write_time(stdPath, ec)
+                                    .time_since_epoch())
+                                .count();
+
+        auto it = cache.find(stdPath);
+        if (it != cache.end()) {
+          if (it->second.file_size == static_cast<int64_t>(currentSize) &&
+              it->second.timestamp == static_cast<int64_t>(currentMtime)) {
+            return {}; // キャッシュと一致する場合はDBの再書き込みを避けるため空を返す
+          }
         }
- 
-        // 3. 並列ハッシュ計算 (mapped)
-        auto processFunc = [&cache](const std::string& stdPath) -> ImageData {
-            try {
-                std::error_code ec;
-                auto currentSize = std::filesystem::file_size(stdPath, ec);
-                auto currentMtime = std::chrono::duration_cast<std::chrono::seconds>(
-                    std::filesystem::last_write_time(stdPath, ec).time_since_epoch()).count();
- 
-                auto it = cache.find(stdPath);
-                if (it != cache.end()) {
-                    if (it->second.file_size == static_cast<int64_t>(currentSize) && 
-                        it->second.timestamp == static_cast<int64_t>(currentMtime)) {
-                        return {}; // キャッシュと一致する場合はDBの再書き込みを避けるため空を返す
-                    }
-                }
- 
-                cv::Mat img = ImageHasher::loadImage(stdPath);
-                ImageData data;
-                data.path = stdPath;
-                data.is_searched = false;
-                if (!img.empty()) {
-                    data.dhash = ImageHasher::calculateDHash(img);
-                    data.phash = ImageHasher::calculatePHash(img);
-                }
-                data.timestamp = currentMtime;
-                data.file_size = currentSize;
-                return data;
-            } catch (...) {
-                return {};
-            }
-        };
- 
-        // 並列実行
-        auto results = QtConcurrent::blockingMapped(allFiles, processFunc);
- 
-        // 4. DBへの一括保存 (メインスレッドの管理外のDB接続で行う)
-        DatabaseManager db("dupfind_cache.db");
-        if (db.open()) {
-            db.cleanupStaleEntries(); // DBに存在して実体がないファイルを削除
-            db.beginTransaction();
-            for (const auto& data : results) {
-                if (data.path.empty()) continue; // 全く変更がないファイルはスキップ
-                db.addImage(data);
-            }
-            db.commitTransaction();
+
+        cv::Mat img = ImageHasher::loadImage(stdPath);
+        ImageData data;
+        data.path = stdPath;
+        data.is_searched = false;
+        if (!img.empty()) {
+          data.dhash = ImageHasher::calculateDHash(img);
+          data.phash = ImageHasher::calculatePHash(img);
         }
-    });
- 
-    m_scanWatcher->setFuture(future);
+        data.timestamp = currentMtime;
+        data.file_size = currentSize;
+        return data;
+      } catch (...) {
+        return {};
+      }
+    };
+
+    // 並列実行
+    auto results = QtConcurrent::blockingMapped(allFiles, processFunc);
+
+    // 4. DBへの一括保存 (メインスレッドの管理外のDB接続で行う)
+    DatabaseManager db("dupfind_cache.db");
+    if (db.open()) {
+      db.cleanupStaleEntries(); // DBに存在して実体がないファイルを削除
+      db.beginTransaction();
+      for (const auto &data : results) {
+        if (data.path.empty())
+          continue; // 全く変更がないファイルはスキップ
+        db.addImage(data);
+      }
+      db.commitTransaction();
+    }
+  });
+
+  m_scanWatcher->setFuture(future);
 }
 
 void MainWindow::onScanFinished() {
-    m_startScanBtn->setEnabled(true);
- 
-    auto images = getFilteredImages();
-    m_lastScannedImages = images; // キャッシュを更新
-    
-    // スキャン後は同期処理で固まらないように非同期検索を起動する
-    performAsyncSearch();
+  m_startScanBtn->setEnabled(true);
+
+  auto images = getFilteredImages();
+  m_lastScannedImages = images; // キャッシュを更新
+
+  // スキャン後は同期処理で固まらないように非同期検索を起動する
+  performAsyncSearch();
 }
 
 void MainWindow::onThresholdChanged(int value) {
-    m_currentThreshold = value;
-    m_thresholdLabel->setText(QString::number(value));
-    
-    // 操作が止まるまで待機(デバウンス)
-    m_searchTimer->start();
+  m_currentThreshold = value;
+  m_thresholdLabel->setText(QString::number(value));
+
+  // 操作が止まるまで待機(デバウンス)
+  m_searchTimer->start();
 }
 
 void MainWindow::onStrictChanged(int state) {
-    m_strictMode = (state == Qt::Checked);
-    m_searchTimer->start();
+  m_strictMode = (state == Qt::Checked);
+  m_searchTimer->start();
 }
- 
+
+// ハミング距離などに基づき、メモリ上の全画像データから重複グループを非同期で抽出・クラスタリングする
 void MainWindow::performAsyncSearch() {
-    if (m_lastScannedImages.empty()) {
-        m_lastScannedImages = getFilteredImages();
-    }
-    
-    if (m_lastScannedImages.empty()) return;
- 
-    m_progressBar->setVisible(true);
-    m_progressBar->setRange(0, 0);
-    m_progressBar->setFormat("Searching duplicates... %p%");
- 
-    // 現在実行中の検索があればキャンセルはできないが、WatcherのFutureを上書きすることで最新のみを追う
-    auto future = QtConcurrent::run([images = m_lastScannedImages, threshold = m_currentThreshold, strict = m_strictMode]() {
-        return SimilaritySearch::findDuplicates(images, threshold, strict);
-    });
-    
-    m_searchWatcher->setFuture(future);
+  if (m_lastScannedImages.empty()) {
+    m_lastScannedImages = getFilteredImages();
+  }
+
+  if (m_lastScannedImages.empty())
+    return;
+
+  m_progressBar->setVisible(true);
+  m_progressBar->setRange(0, 0);
+  m_progressBar->setFormat("Searching duplicates... %p%");
+
+  // 現在実行中の検索があればキャンセルはできないが、WatcherのFutureを上書きすることで最新のみを追う
+  auto future = QtConcurrent::run([images = m_lastScannedImages,
+                                   threshold = m_currentThreshold,
+                                   strict = m_strictMode]() {
+    return SimilaritySearch::findDuplicates(images, threshold, strict);
+  });
+
+  m_searchWatcher->setFuture(future);
 }
- 
+
 void MainWindow::onSearchFinished() {
-    m_progressBar->setVisible(false);
-    updateResultGrid(m_searchWatcher->result());
-    
-    // サムネイルが表示された時点で検索対象ディレクトリ群を検索済みとする
-    for (int i = 0; i < m_dirList->count(); ++i) {
-        QString dirPath = m_dirList->item(i)->text();
-        m_dbManager->setDirectorySearchedStatus(dirPath.toStdString(), true);
+  m_progressBar->setVisible(false);
+  m_currentGroups = m_searchWatcher->result();
+  updateResultGrid(m_currentGroups);
+
+  // サムネイルが表示された時点で検索対象ディレクトリ群を検索済みとする
+  for (int i = 0; i < m_dirList->count(); ++i) {
+    QString dirPath = m_dirList->item(i)->text();
+    m_dbManager->setDirectorySearchedStatus(dirPath.toStdString(), true);
+  }
+  // メモリ上のキャッシュも更新する
+  m_lastScannedImages = getFilteredImages();
+}
+
+void MainWindow::removeGroupFromView(int groupId) {
+  if (groupId >= 0 && groupId < static_cast<int>(m_currentGroups.size())) {
+    for (const auto& img : m_currentGroups[groupId].images) {
+      m_ignoredPaths.insert(img.path);
     }
-    // メモリ上のキャッシュも更新する
     m_lastScannedImages = getFilteredImages();
+    m_currentGroups.erase(m_currentGroups.begin() + groupId);
+    updateResultGrid(m_currentGroups, true);
+  }
 }
- 
+
 void MainWindow::onClearResults() {
-    // UIをクリア
-    QLayoutItem *child;
-    while ((child = m_resultLayout->takeAt(0)) != nullptr) {
-        if (child->widget()) delete child->widget();
-        delete child;
-    }
-    m_resultItems.clear();
+  // UIをクリア
+  QLayoutItem *child;
+  while ((child = m_resultLayout->takeAt(0)) != nullptr) {
+    if (child->widget())
+      delete child->widget();
+    delete child;
+  }
+  for (int i = 0; i < m_resultLayout->rowCount(); ++i) {
+    m_resultLayout->setRowStretch(i, 0);
+  }
+  m_resultItems.clear();
 }
- 
+
 bool MainWindow::eventFilter(QObject *obj, QEvent *event) {
-    if (event->type() == QEvent::MouseButtonDblClick) {
-        QString path = obj->property("filePath").toString();
-        if (!path.isEmpty()) {
-            QDesktopServices::openUrl(QUrl::fromLocalFile(path));
-            return true;
-        }
+  if (event->type() == QEvent::MouseButtonDblClick) {
+    QString path = obj->property("filePath").toString();
+    if (!path.isEmpty()) {
+      QDesktopServices::openUrl(QUrl::fromLocalFile(path));
+      return true;
+    }
+  } else if (event->type() == QEvent::ContextMenu) {
+    QString path = obj->property("filePath").toString();
+    if (!path.isEmpty()) {
+      QContextMenuEvent *ce = static_cast<QContextMenuEvent *>(event);
+      QMenu menu;
+      QAction *copyAction = menu.addAction("Copy Full Path(&C)");
+      menu.addSeparator();
+      QAction *removeAction = menu.addAction("Remove from List(&R)");
+      
+      QAction *selectedAction = menu.exec(ce->globalPos());
+      if (selectedAction == copyAction) {
+        QApplication::clipboard()->setText(path);
+      } else if (selectedAction == removeAction) {
+        int groupId = obj->property("groupId").toInt();
+        removeGroupFromView(groupId);
+      }
+      return true;
     }
-    return QMainWindow::eventFilter(obj, event);
+  }
+  return QMainWindow::eventFilter(obj, event);
 }
 
-void MainWindow::updateResultGrid(const std::vector<DuplicateGroup>& groups) {
-    // UIをクリア
-    QLayoutItem *child;
-    while ((child = m_resultLayout->takeAt(0)) != nullptr) {
-        if (child->widget()) delete child->widget();
-        delete child;
+// 同一・類似と判定された画像のグループを受け取り、UI上のグリッドレイアウトへ動的にサムネイルと削除候補のチェックボックスを描画する
+void MainWindow::updateResultGrid(const std::vector<DuplicateGroup> &groups, bool preserveState) {
+  // 状態の保存
+  std::unordered_map<std::string, bool> previousState;
+  if (preserveState) {
+    for (const auto& item : m_resultItems) {
+      if (item.checkbox) {
+        previousState[item.path] = item.checkbox->isChecked();
+      }
+    }
+  }
+
+  // UIをクリア
+  QLayoutItem *child;
+  while ((child = m_resultLayout->takeAt(0)) != nullptr) {
+    if (child->widget())
+      delete child->widget();
+    delete child;
+  }
+  for (int i = 0; i < m_resultLayout->rowCount(); ++i) {
+    m_resultLayout->setRowStretch(i, 0);
+  }
+  m_resultItems.clear();
+
+  int row = 0;
+  int groupId = 0;
+  for (const auto &group : groups) {
+    // グループ内で残す1枚(ファイルサイズが最大のもの。同サイズなら最初の1枚)を特定する
+    const ImageData *bestImage = &group.images[0];
+    for (size_t i = 1; i < group.images.size(); ++i) {
+      if (group.images[i].file_size > bestImage->file_size) {
+        bestImage = &group.images[i];
+      }
     }
-    m_resultItems.clear();
-
-    int row = 0;
-    for (const auto& group : groups) {
-        // グループ内で残す1枚(ファイルサイズが最大のもの。同サイズなら最初の1枚)を特定する
-        const ImageData* bestImage = &group.images[0];
-        for (size_t i = 1; i < group.images.size(); ++i) {
-            if (group.images[i].file_size > bestImage->file_size) {
-                bestImage = &group.images[i];
-            }
-        }
 
-        // グループヘッダー
-        QLabel* groupLabel = new QLabel(QString("Duplicate Group - %1 images").arg(group.images.size()));
-        groupLabel->setStyleSheet("font-weight: bold; background-color: #f0f0f0; padding: 5px; border-radius: 4px;");
-        m_resultLayout->addWidget(groupLabel, row++, 0, 1, 4);
-
-        int col = 0;
-        for (const auto& imgData : group.images) {
-            QWidget* imgWidget = new QWidget();
-            imgWidget->setObjectName("imgCard");
-            QVBoxLayout* vBox = new QVBoxLayout(imgWidget);
-            
-            QLabel* thumb = new QLabel();
-            thumb->setProperty("filePath", QString::fromStdString(imgData.path));
-            thumb->installEventFilter(this);
-            thumb->setToolTip("Double click to open");
-  
-            QImageReader reader(QString::fromStdString(imgData.path));
-            reader.setAutoTransform(true);
-            reader.setAllocationLimit(512); // デフォルト128MB制限を512MBに引き上げ
-
-            QSize imgSize = reader.size();
-            if (imgSize.isValid()) {
-                // サムネイル表示に必要なサイズ(高階調な表示のためここでは高めでもよいが、150x150枠)に
-                // デコード段階で縮小指定する。JPEG等では飛躍的に高速化・省メモリ化される。
-                imgSize.scale(300, 300, Qt::KeepAspectRatio);
-                reader.setScaledSize(imgSize);
-            }
-
-            QImage img = reader.read();
-            if (!img.isNull()) {
-                QPixmap pix = QPixmap::fromImage(img);
-                thumb->setPixmap(pix.scaled(150, 150, Qt::KeepAspectRatio, Qt::SmoothTransformation));
-            } else {
-                thumb->setText("Error Loading");
-            }
-            thumb->setAlignment(Qt::AlignCenter);
-            vBox->addWidget(thumb);
-
-            // 自動チェック: 残す1枚(bestImage)以外を削除候補としてチェックする
-            QCheckBox* cb = new QCheckBox("Delete candidate");
-            if (&imgData != bestImage) {
-                cb->setChecked(true);
-            }
-            vBox->addWidget(cb);
-            
-            QLabel* infoLabel = new QLabel(QString("%1 KB\n%2").arg(imgData.file_size / 1024).arg(QString::fromStdString(imgData.path)));
-            infoLabel->setWordWrap(true);
-            infoLabel->setMaximumWidth(150);
-            infoLabel->setStyleSheet("font-size: 10px; color: #666;");
-            vBox->addWidget(infoLabel);
-
-            m_resultLayout->addWidget(imgWidget, row, col);
-            m_resultItems.push_back({cb, imgData.path});
-
-            col++;
-            if (col >= 4) {
-                col = 0;
-                row++;
-            }
+    // グループヘッダー
+    QLabel *groupLabel = new QLabel(
+        QString("Duplicate Group - %1 images").arg(group.images.size()));
+    groupLabel->setStyleSheet("font-weight: bold; background-color: #f0f0f0; "
+                              "padding: 5px; border-radius: 4px;");
+    m_resultLayout->addWidget(groupLabel, row++, 0, 1, 4);
+
+    int col = 0;
+    for (const auto &imgData : group.images) {
+      QWidget *imgWidget = new QWidget();
+      imgWidget->setObjectName("imgCard");
+      QVBoxLayout *vBox = new QVBoxLayout(imgWidget);
+
+      QLabel *thumb = new QLabel();
+      thumb->setProperty("filePath", QString::fromStdString(imgData.path));
+      thumb->setProperty("groupId", groupId);
+      thumb->installEventFilter(this);
+      thumb->setToolTip("Double click to open\nRight click to copy path or remove from list");
+
+      QImageReader reader(QString::fromStdString(imgData.path));
+      reader.setAutoTransform(true);
+      reader.setAllocationLimit(512); // デフォルト128MB制限を512MBに引き上げ
+
+      QSize imgSize = reader.size();
+      if (imgSize.isValid()) {
+        // サムネイル表示に必要なサイズ(高階調な表示のためここでは高めでもよいが、150x150枠)に
+        // デコード段階で縮小指定する。JPEG等では飛躍的に高速化・省メモリ化される。
+        imgSize.scale(300, 300, Qt::KeepAspectRatio);
+        reader.setScaledSize(imgSize);
+      }
+
+      QImage img = reader.read();
+      QPixmap pix;
+      if (!img.isNull()) {
+        pix = QPixmap::fromImage(img);
+      } else {
+        // Qtの標準ImageReaderで読み込めない形式(WebPプラギン未導入時など)のフォールバック
+        cv::Mat cvImg = ImageHasher::loadImage(imgData.path, 300);
+        if (!cvImg.empty()) {
+          cv::Mat rgb;
+          cv::cvtColor(cvImg, rgb, cv::COLOR_BGR2RGB);
+          QImage qimg(rgb.data, rgb.cols, rgb.rows, rgb.step,
+                      QImage::Format_RGB888);
+          pix = QPixmap::fromImage(
+              qimg.copy()); // OpenCVのMatデータ揮発を防ぐためcopy()
+        }
+      }
+
+      if (!pix.isNull()) {
+        thumb->setPixmap(pix.scaled(150, 150, Qt::KeepAspectRatio,
+                                    Qt::SmoothTransformation));
+      } else {
+        thumb->setText("Error Loading");
+      }
+      thumb->setAlignment(Qt::AlignCenter);
+      vBox->addWidget(thumb);
+
+      // 自動チェック: 残す1枚(bestImage)以外を削除候補としてチェックする
+      QCheckBox *cb = new QCheckBox("Delete candidate");
+      if (preserveState && previousState.find(imgData.path) != previousState.end()) {
+        cb->setChecked(previousState[imgData.path]);
+      } else {
+        if (&imgData != bestImage) {
+          cb->setChecked(true);
         }
+      }
+      vBox->addWidget(cb);
+
+      QLabel *infoLabel =
+          new QLabel(QString("%1 KB\n%2")
+                         .arg(imgData.file_size / 1024)
+                         .arg(QString::fromStdString(imgData.path)));
+      infoLabel->setWordWrap(true);
+      infoLabel->setMaximumWidth(150);
+      infoLabel->setStyleSheet("font-size: 10px; color: #666;");
+      vBox->addWidget(infoLabel);
+
+      m_resultLayout->addWidget(imgWidget, row, col);
+      m_resultItems.push_back({cb, imgData.path, groupId});
+
+      col++;
+      if (col >= 4) {
+        col = 0;
         row++;
+      }
     }
+    row++;
+    groupId++;
+  }
+
+  // 項目数が少ない時に各行が縦に間延びする(ヘッダーが極端に太くなる)のを防ぐため、
+  // 最後の空行に対して余った縦スペースをすべて吸収させる
+  m_resultLayout->setRowStretch(row, 1);
 }
 
 void MainWindow::onDeleteSelected() {
-    int count = 0;
-    for (const auto& item : m_resultItems) {
-        if (item.checkbox->isChecked()) {
-            count++;
-        }
+  int count = 0;
+  std::map<int, int> groupTotalCount;
+  std::map<int, int> groupCheckedCount;
+
+  for (const auto &item : m_resultItems) {
+    groupTotalCount[item.groupId]++;
+    if (item.checkbox->isChecked()) {
+      groupCheckedCount[item.groupId]++;
+      count++;
     }
+  }
 
-    if (count == 0) return;
-
-    auto res = QMessageBox::question(this, "Confirm Deletion", 
-        QString("Are you sure you want to move %1 images to Trash?").arg(count));
-    
-    if (res == QMessageBox::Yes) {
-        std::vector<QString> failures;
-        for (const auto& item : m_resultItems) {
-            if (item.checkbox->isChecked()) {
-                QString qPath = QString::fromStdString(item.path);
-                // QFile::moveToTrash は Qt 5.15+ で利用可能
-                if (QFile::moveToTrash(qPath)) {
-                    m_dbManager->removeImage(item.path);
-                } else {
-                    failures.push_back(qPath);
-                }
-            }
-        }
+  if (count == 0)
+    return;
 
-        if (!failures.empty()) {
-            QString msg = "The following files could not be moved to trash and were NOT deleted:\n\n";
-            for (const auto& f : failures) {
-                msg += f + "\n";
-            }
-            QMessageBox::warning(this, "Deletion Error", msg);
+  bool allCheckedInSomeGroup = false;
+  for (auto const &[gId, total] : groupTotalCount) {
+    if (groupCheckedCount[gId] == total) {
+      allCheckedInSomeGroup = true;
+      break;
+    }
+  }
+
+  if (allCheckedInSomeGroup) {
+    auto warnRes = QMessageBox::warning(
+        this, "Warning: All images selected",
+        "一部の類似画像グループで、すべての画像が削除対象としてチェックされてい"
+        "ます。\n"
+        "このまま削除すると、それらの画像ファイルはすべて失われます。\n\n"
+        "本当に削除を実行しますか?",
+        QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
+    if (warnRes != QMessageBox::Ok) {
+      return;
+    }
+  }
+
+  auto res = QMessageBox::question(
+      this, "Confirm Deletion",
+      QString("Are you sure you want to move %1 images to Trash?").arg(count));
+
+  if (res == QMessageBox::Yes) {
+    std::vector<QString> failures;
+    for (const auto &item : m_resultItems) {
+      if (item.checkbox->isChecked()) {
+        QString qPath = QString::fromStdString(item.path);
+        // QFile::moveToTrash は Qt 5.15+ で利用可能
+        if (QFile::moveToTrash(qPath)) {
+          m_dbManager->removeImage(item.path);
+        } else {
+          failures.push_back(qPath);
         }
+      }
+    }
 
-        // リストをリフレッシュ
-        auto images = getFilteredImages();
-        auto groups = SimilaritySearch::findDuplicates(images, m_currentThreshold, m_strictMode);
-        updateResultGrid(groups);
+    if (!failures.empty()) {
+      QString msg = "The following files could not be moved to trash and were "
+                    "NOT deleted:\n\n";
+      for (const auto &f : failures) {
+        msg += f + "\n";
+      }
+      QMessageBox::warning(this, "Deletion Error", msg);
     }
+
+    // リストをリフレッシュ
+    auto images = getFilteredImages();
+    m_currentGroups = SimilaritySearch::findDuplicates(images, m_currentThreshold,
+                                                   m_strictMode);
+    updateResultGrid(m_currentGroups);
+  }
 }