├── requirements.txt ├── .gitignore ├── start.ps1 ├── start.bat ├── LICENSE ├── app_config.json ├── README.md └── TagFlow.py /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | Pillow 3 | PySide6 4 | pillow_heif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python virtual environment 2 | main_new.py_bak 3 | venv/ 4 | README.md 5 | -------------------------------------------------------------------------------- /start.ps1: -------------------------------------------------------------------------------- 1 | # PowerShell 用スタートアップスクリプト 2 | if (!(Test-Path -Path "venv")) { 3 | python -m venv venv 4 | } 5 | . .\venv\Scripts\Activate.ps1 6 | pip install -r requirements.txt 7 | python TagFlow.py 8 | Pause 9 | -------------------------------------------------------------------------------- /start.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | REM このファイルをダブルクリックで実行してください 3 | if not exist venv ( 4 | python -m venv venv 5 | ) 6 | call venv\Scripts\activate.bat 7 | pip install -r requirements.txt 8 | python TagFlow.py 9 | pause 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 AiWithYou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "clean_patterns": { 3 | "initial": [ 4 | "^Here.*?:", 5 | "^説明[::]\\s*", 6 | "^This image shows", 7 | "^The image shows", 8 | "^In this image,", 9 | "^The image depicts", 10 | "^This image depicts", 11 | "^The photo shows", 12 | "^The picture shows", 13 | "^I will describe", 14 | "^I'll describe", 15 | "^Let me describe", 16 | "^I can see", 17 | "^画像には", 18 | "^この画像には", 19 | "^この画像は", 20 | "^この絵には", 21 | "^この絵は", 22 | "^この写真には", 23 | "^この写真は", 24 | "^写真には", 25 | "^画像は", 26 | "^この画像を.*?で説明します。?\\n?", 27 | "^以下.*?で説明します。?\\n?", 28 | "^、\\s*" 29 | ], 30 | "additional": [ 31 | "^また、?", 32 | "^そして、?", 33 | "^なお、?", 34 | "^さらに、?", 35 | "^加えて、?", 36 | "^特に、?", 37 | "^具体的には、?", 38 | "^Additionally,\\s*", 39 | "^Moreover,\\s*", 40 | "^Furthermore,\\s*", 41 | "^Also,\\s*", 42 | "^And\\s*", 43 | "^Specifically,\\s*", 44 | "^There\\s+(?:is|are)\\s+", 45 | "^We\\s+can\\s+see\\s+", 46 | "^You\\s+can\\s+see\\s+", 47 | "^It\\s+appears\\s+", 48 | "これは", 49 | "それは", 50 | "以下は", 51 | "次のような", 52 | "(?:以下の)?(?:画像に適用できる)?(?:Danbooru|だんぼーる|ダンボール|ダンボーる)?タグ(?:です|となります)。?\\n?", 53 | "タグ(?:一覧|リスト):\\n?", 54 | "^[**・]", 55 | "^、", 56 | "主な(?:特徴|要素)(?::|は)(?:以下の)?(?:通り|とおり)(?:です)?。?\\n?" 57 | ] 58 | } 59 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TagFlow: AI画像タグ付け・ファイル管理ツール 2 | 3 | Ollamaを利用したAIモデルで画像にタグ(説明文)を付け、そのタグ情報に基づいて画像ファイルを効率的に検索・整理するためのデスクトップアプリケーションです。 4 | 5 | ## 主な機能 6 | 7 | 本ツールは、以下の主要なタブと機能を提供します。 8 | 9 | ### 1. 画像タグ付けタブ (`ImageTaggingTab`) 10 | 11 | - **AIによる画像分析:** 12 | - ローカルで動作するOllama API (`http://localhost:11434/api/generate` がデフォルト) と連携し、選択した画像の内容を分析します。 13 | - 使用するOllamaモデル(例: `gemma3:27b`)やAPIのURLはツールバーから変更可能です。 14 | - 出力言語(日本語/英語)、説明の詳細度(簡潔/標準/詳細)を選択できます。 15 | - 「詳細設定」からカスタムプロンプトを入力し、より具体的な指示を与えることも可能です。 16 | - 分析結果から不要な前置き表現("Here is a description..." など)を自動で削除するクリーニング機能があります(カスタムプロンプト使用時も有効/無効を選択可能)。 17 | - **ファイル操作:** 18 | - 画像ファイルやフォルダをドラッグ&ドロップ、またはボタン操作でリストに追加できます。 19 | - 対応フォーマット: JPG, JPEG, PNG, GIF, BMP, WEBP, HEIC, AVIF (HEIC/AVIFは `pillow_heif` が必要)。 20 | - リストから画像を選択して削除できます。 21 | - **プレビューと結果表示:** 22 | - 選択した画像のプレビューを表示します。 23 | - 分析結果(タグ)は画像と同じフォルダに `.txt` ファイルとして保存され、画面下部にも表示されます。 24 | - **非同期処理:** 25 | - 画像分析はバックグラウンドで実行され、プログレスバーで進捗を確認できます。処理中の停止も可能です。 26 | 27 | ![image](https://github.com/user-attachments/assets/bb7b15fd-5c89-4e33-823b-517d3b4f97ff) 28 | 29 | 30 | ### 2. ファイル移動タブ (`FileMoveTab`) 31 | 32 | - **タグベースのファイル検索:** 33 | - 指定したソースフォルダ内にある画像に対応する `.txt` ファイルの内容を検索します。 34 | - 複数のキーワードをカンマ区切りで指定し、AND検索またはOR検索を実行できます。 35 | - 正規表現による高度な検索も可能です。 36 | - **検索結果の操作:** 37 | - 検索に一致した画像ファイルをリスト表示します。 38 | - リストからファイルを選択し、指定した宛先フォルダへ一括でコピーまたは移動できます。 39 | - ファイル操作時、対応する `.txt` ファイルも一緒にコピー/移動されます。 40 | - 宛先に同名ファイルが存在する場合は、自動的に連番が付与されます(例: `image_1.jpg`)。 41 | - **非同期処理:** 42 | - ファイル検索とコピー/移動処理はバックグラウンドで実行され、プログレスバーで進捗を確認できます。 43 | 44 | ![image](https://github.com/user-attachments/assets/c3a82446-cded-44f1-94dd-20b4e51754d3) 45 | 46 | 47 | ### 3. 分析結果編集タブ (`ResultEditTab`) 48 | 49 | - **テキストファイルの一括編集:** 50 | - 指定したフォルダ内にある画像に対応する `.txt` ファイルの内容を一括で編集できます。 51 | - 特定の文字列の削除、テキストの先頭/末尾への追加、文字列の置換が可能です。 52 | - **個別編集とプレビュー:** 53 | - 画像ファイルを選択すると、プレビューと対応する `.txt` ファイルの内容が表示されます。 54 | - テキストエディタで直接内容を編集し、個別に保存できます。 55 | 56 | ![image](https://github.com/user-attachments/assets/fda5ecf7-0cd8-447b-878a-7cd83b1353fb) 57 | 58 | 59 | ### 4. AIチャットタブ (`ChatTab`) 60 | 61 | - **Ollamaとの対話:** 62 | - 画像タグ付けタブで設定したOllamaモデル・URLを使用して、テキストベースのチャットができます。 63 | - タグ付けのルール相談や、アイデア出しなどに活用できます。 64 | - Shift+Enterでメッセージを送信できます。 65 | 66 | ![image](https://github.com/user-attachments/assets/2f5d15ab-509e-4fdd-a5f5-cd5e68bded6a) 67 | 68 | 69 | ### 5. 設定機能 (メニューバー) 70 | 71 | - **削除パターン設定:** 72 | - 画像分析結果から自動削除するパターン(正規表現)を編集・保存・読み込みできます。 73 | - 初期クリーニング(前置き削除など)と追加クリーニング(接続詞削除など)の2段階で設定可能です。 74 | - 設定したパターンがサンプルテキストにどう影響するかをテストする機能もあります。 75 | - **AIサーバ管理:** 76 | - ローカルのOllamaサーバ (`ollama serve`) をアプリケーションから起動・停止できます(Windowsでは別コンソール、macOS/Linuxではバックグラウンド)。 77 | 78 | ![image](https://github.com/user-attachments/assets/7ed7d31e-1c2f-489d-8483-263bf9872c2b) 79 | 80 | ![image](https://github.com/user-attachments/assets/0d8abe93-1d86-4c45-9b7a-6e0ae870ce4d) 81 | 82 | 83 | ## ファイル構成 84 | 85 | ``` 86 | h:/AI/TagFlow/ 87 | │ .gitignore # Git管理対象外ファイル定義 88 | │ app_config.json # アプリケーション設定保存ファイル (モデル名, URL, 削除パターン等) 89 | │ README.md # このファイル 90 | │ requirements.txt # Python依存ライブラリリスト 91 | │ start.bat # Windows用 起動バッチファイル 92 | │ start.ps1 # Windows用 PowerShell起動スクリプト 93 | │ TagFlow.py # メインのPythonアプリケーションコード 94 | │ 95 | ├─ .git/ # Gitリポジトリ管理用ディレクトリ 96 | └─ venv/ # Python仮想環境 (例) 97 | ``` 98 | 99 | ## 動作環境 100 | 101 | - Python 3.8以上 102 | - **Ollama:** ローカル環境にインストールされ、実行可能であること。 103 | - **Pythonライブラリ:** (`requirements.txt` 参照) 104 | - `PySide6`: GUIフレームワーク 105 | - `Pillow`: 画像処理ライブラリ 106 | - `requests`: Ollama APIとの通信用 107 | - `pillow_heif` (オプション): HEIC/HEIF画像形式のサポートに必要 108 | 109 | ## インストール方法 110 | 111 | 1. **Ollamaのインストール:** 公式サイト ([https://ollama.com/](https://ollama.com/)) の手順に従ってOllamaをインストールし、使用したいモデル(例: `gemma3:27b`)をダウンロードしておきます (`ollama pull gemma3:27b`)。 112 | 2. **Python環境の準備:** Python 3.8以上がインストールされていることを確認してください。仮想環境の使用を推奨します。 113 | ```bash 114 | python -m venv venv 115 | # Windows 116 | .\venv\Scripts\activate 117 | # macOS/Linux 118 | source venv/bin/activate 119 | ``` 120 | 3. **必要なPythonライブラリのインストール:** 121 | ```bash 122 | pip install -r requirements.txt 123 | # または個別にインストール 124 | # pip install PySide6 Pillow requests pillow-heif 125 | ``` 126 | 127 | ## 使用方法 128 | 129 | ### アプリケーションの起動 130 | 131 | - **Windows:** 132 | - `start.bat` をダブルクリックするか、PowerShellで `.\start.ps1` を実行します。 133 | - または、コマンドプロンプト/PowerShellで `python TagFlow.py` を実行します。 134 | - **macOS/Linux:** 135 | - ターミナルで `python TagFlow.py` を実行します。 136 | 137 | ### 画像タグ付け 138 | 139 | 1. 「画像タグ付け」タブを選択します。 140 | 2. Ollamaモデル名とAPI URLを確認・設定します(必要であれば)。 141 | 3. 日本語で出力させたい場合は「日本語で出力」にチェックを入れます。 142 | 4. 「詳細設定」ボタンから、必要に応じてカスタムプロンプトや説明の詳細度を設定します。 143 | 5. 画像ファイルまたはフォルダをリストエリアにドラッグ&ドロップするか、「フォルダ追加」「ファイル追加」ボタンで追加します。 144 | 6. 分析したい画像をリストから選択します(複数選択可)。何も選択しない場合はリスト内の全画像が対象になります。 145 | 7. 「分析実行」ボタンをクリックします。 146 | 8. 処理が完了すると、各画像と同じフォルダに `.txt` ファイルが作成/上書きされます。リストで画像を選択すると、対応するテキストが右下に表示されます。 147 | 148 | ### ファイル移動 149 | 150 | 1. 「ファイル移動」タブを選択します。 151 | 2. 「ソースフォルダ」と「宛先フォルダ」を「参照」ボタンで選択します。 152 | 3. 検索したいキーワードをカンマ区切りで入力します。 153 | 4. 検索モード(AND/OR)と正規表現の使用有無を選択します。 154 | 5. 「検索」ボタンをクリックします。 155 | 6. 一致した画像が結果リストに表示されます。 156 | 7. コピー/移動したい画像を結果リストから選択します(複数選択可)。「すべて選択」「選択解除」ボタンも利用できます。 157 | 8. 「コピー」または「移動」ボタンをクリックします。 158 | 159 | ### 分析結果編集 160 | 161 | 1. 「分析結果編集」タブを選択します。 162 | 2. 「フォルダ選択」ボタンで、編集したい `.txt` ファイルが含まれるフォルダを選択します。 163 | 3. 左側のリストに画像が表示されるので、編集したい画像を選択します。 164 | 4. 右側のプレビューで画像を確認し、テキストエリアで `.txt` の内容を編集します。 165 | 5. 個別に編集を保存する場合は「変更を保存」ボタンをクリックします。 166 | 6. フォルダ内の全 `.txt` ファイルに対して一括で編集(削除、先頭/末尾追加、置換)を行いたい場合は、「一括操作」欄に必要な情報を入力し、「一括実行」ボタンをクリックします。実行ログは下部のテキストエリアに表示されます。 167 | 168 | ### AIチャット 169 | 170 | 1. 「AIチャット」タブを選択します。 171 | 2. 下部の入力欄にメッセージを入力し、「送信」ボタンをクリックするか、Shift+Enterキーを押します。 172 | 3. AIからの応答がチャット欄に表示されます。 173 | * 注意: このチャットで使用されるモデルとURLは、「画像タグ付け」タブのツールバーで設定されているものが使用されます。 174 | 175 | ### 設定 176 | 177 | - **削除パターン:** メニューバーの「設定」→「削除パターン設定」を選択します。表示されるダイアログで、削除したいパターンの正規表現を編集し、「設定ファイルを保存」で `app_config.json` などに保存できます。次回起動時もこの設定が読み込まれます。「パターンテスト」で効果を確認できます。 178 | - **詳細設定:** 「画像タグ付け」タブの「詳細設定」ボタンをクリックします。カスタムプロンプトや詳細度を設定し、「OK」で一時的に適用するか、「設定ファイルの保存先選択」で設定ファイルに保存できます。 179 | 180 | ## カスタマイズ 181 | 182 | - **Ollama URL/モデル:** アプリケーションのツールバーから直接変更できます。変更内容は `app_config.json` に保存され、次回起動時に読み込まれます。 183 | - **プロンプト/詳細度:** 「詳細設定」ダイアログから変更・保存できます。 184 | - **削除パターン:** 「削除パターン設定」ダイアログから編集・保存できます。 185 | - **設定ファイル (`app_config.json`):** アプリケーション終了時に、モデル名、API URL、日本語設定、カスタムプロンプト設定、詳細度設定、削除パターンが自動的に `app_config.json` に保存されます。このファイルを直接編集することも可能です。 186 | 187 | ## 注意事項 188 | 189 | - **Ollamaの実行:** このツールを使用するには、Ollamaがローカル環境で実行可能である必要があります。必要に応じて、メニューバーの「AIサーバ」→「AIサーバ起動」で `ollama serve` を起動してください。 190 | - **処理時間:** 大量の画像を一度に分析する場合や、ファイル数が多いフォルダを検索/移動する場合は、処理に時間がかかることがあります。 191 | - **分析精度:** 画像分析の精度は、使用するOllamaモデルの性能に依存します。 192 | - **HEIC/AVIFサポート:** これらの形式の画像を扱うには、`pillow_heif` ライブラリが必要です (`pip install pillow-heif`)。 193 | 194 | 195 | ## ライセンス 196 | 197 | このソフトウェアは商用利用可能です。 198 | ライブラリなどは元のライセンスに従ってください 199 | 200 | ## ユースケース集 201 | 202 | ### 機能別ユースケース 203 | 204 | | 機能 | 典型的ユースケース | 作業時間の短縮例 | 205 | | -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------ | 206 | | AI 画像タグ付け | SNS 投稿前に数百枚の写真へ自動タグ付け | 撮影 → 公開までのタグ付け工数を **90% 削減** | 207 | | タグ検索 + 一括コピー | 「sunset, beach」タグの写真だけフォトフレーム用フォルダにコピー | 手作業での選別を **秒速化** | 208 | | タグ検索 + 一括移動 | 顔写真を自動仕分けしてプライベート領域へ移動 | 誤公開リスクをゼロに | 209 | | TXT 一括編集 | 描画 AI 学習用タグを一括正規化 (`cat`→`cat_(animal)`) | データクリーニング時間を **1/5** | 210 | | AI チャット | Ollama × 自社ローカル LLM でタグルールを相談 | オフライン環境でも **セキュアに質問** | 211 | | 削除パターン GUI | LLM が生成する冗長フレーズ(例: "この画像は...")をワンクリックで除去 | 後工程スクリプトの入力整形が不要 | 212 | | AI サーバ自動起動 | `ollama serve` が未起動でもメニューから起動 | 端末再起動後の**手間ゼロ** | 213 | 214 | ### シナリオ例 215 | 216 | - **商品画像の一括 EC 登録**: タグ付け → 検索 → 移動 → ZIP化までを効率化 217 | - **旅行写真アルバム作成**: 自動タグでイベントや場所ごとに自動分類 218 | - **AI学習データ整備**: タグ付け済みデータの表記揺れ統一や内容調整 219 | - **過去SNS投稿の再利用**: 画像検索 + タグ情報 + AIチャットで投稿文やハッシュタグを改善 220 | - **社内データ保護**: 顔写真を含む画像を検出・自動でアクセス制限フォルダへ移動 221 | -------------------------------------------------------------------------------- /TagFlow.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import base64 5 | import requests 6 | import subprocess 7 | import re 8 | import io 9 | import logging 10 | import sys 11 | import json 12 | import shutil 13 | import platform 14 | from pathlib import Path 15 | from PIL import Image, ImageQt 16 | from threading import Thread 17 | 18 | # PySide6インポート 19 | from PySide6.QtGui import ( 20 | QAction, QPixmap, QIcon, QImage, QColor, QPalette, 21 | QFont, QTextCursor 22 | ) 23 | from PySide6.QtWidgets import ( 24 | QApplication, QMainWindow, QTabWidget, QWidget, QVBoxLayout, QHBoxLayout, 25 | QPushButton, QLabel, QLineEdit, QComboBox, QCheckBox, QProgressBar, 26 | QFileDialog, QSplitter, QListWidget, QListWidgetItem, QTextEdit, 27 | QGroupBox, QRadioButton, QStatusBar, QToolBar, QDialog, QMessageBox, 28 | QGridLayout 29 | ) 30 | from PySide6.QtCore import Qt, QSize, QThread, Signal, QEvent 31 | 32 | # HEIC画像対応(pillow_heifがインストールされている場合) 33 | try: 34 | import pillow_heif 35 | pillow_heif.register_heif_opener() 36 | except ImportError: 37 | pass 38 | 39 | # ----------------- ロギング設定 ----------------- 40 | logging.basicConfig( 41 | level=logging.INFO, 42 | format='%(asctime)s - %(levelname)s - %(message)s' 43 | ) 44 | logger = logging.getLogger(__name__) 45 | 46 | # ----------------- デフォルト削除パターン ----------------- 47 | default_initial_patterns = [ 48 | r"^Here.*?:", # "Here..." で始まる前置きを削除 49 | r"^説明[::]\s*", 50 | r"^This image shows", 51 | r"^The image shows", 52 | r"^In this image,", 53 | r"^The image depicts", 54 | r"^This image depicts", 55 | r"^The photo shows", 56 | r"^The picture shows", 57 | r"^I will describe", 58 | r"^I'll describe", 59 | r"^Let me describe", 60 | r"^I can see", 61 | r"^画像には", 62 | r"^この画像には", 63 | r"^この画像は", 64 | r"^この絵には", 65 | r"^この絵は", 66 | r"^この写真には", 67 | r"^この写真は", 68 | r"^写真には", 69 | r"^画像は", 70 | r"^この画像を.*?で説明します。?\n?", 71 | r"^以下.*?で説明します。?\n?", 72 | r"^、\s*" 73 | ] 74 | 75 | default_additional_patterns = [ 76 | r"^また、?", 77 | r"^そして、?", 78 | r"^なお、?", 79 | r"^さらに、?", 80 | r"^加えて、?", 81 | r"^特に、?", 82 | r"^具体的には、?", 83 | r"^Additionally,\s*", 84 | r"^Moreover,\s*", 85 | r"^Furthermore,\s*", 86 | r"^Also,\s*", 87 | r"^And\s*", 88 | r"^Specifically,\s*", 89 | r"^There\s+(?:is|are)\s+", 90 | r"^We\s+can\s+see\s+", 91 | r"^You\s+can\s+see\s+", 92 | r"^It\s+appears\s+", 93 | r"これは", 94 | r"それは", 95 | r"以下は", 96 | r"次のような", 97 | r"(?:以下の)?(?:画像に適用できる)?(?:Danbooru|だんぼーる|ダンボール|ダンボーる)?タグ(?:です|となります)。?\n?", 98 | r"タグ(?:一覧|リスト):\n?", 99 | r"^[**・]", 100 | r"^、", 101 | r"主な(?:特徴|要素)(?::|は)(?:以下の)?(?:通り|とおり)(?:です)?。?\n?" 102 | ] 103 | 104 | # ----------------- ユーティリティ関数 ----------------- 105 | def load_app_config(filepath=None): 106 | """ 107 | JSON設定ファイルを読み込み、辞書を返す 108 | """ 109 | config_file = Path(filepath) if filepath else Path("app_config.json") 110 | if config_file.exists(): 111 | try: 112 | with open(config_file, "r", encoding="utf-8") as f: 113 | config_data = json.load(f) 114 | logger.info(f"設定ファイル {config_file} から読み込みました。") 115 | return config_data 116 | except (json.JSONDecodeError, OSError) as e: 117 | logger.warning(f"設定ファイル読み込みエラー: {e}") 118 | else: 119 | logger.info("設定ファイルが見つかりません。空の設定を使用します。") 120 | return {} 121 | 122 | def save_app_config(config, filepath=None): 123 | """ 124 | 設定をJSONファイルに保存する 125 | """ 126 | config_file = Path(filepath) if filepath else Path("app_config.json") 127 | try: 128 | with open(config_file, "w", encoding="utf-8") as f: 129 | json.dump(config, f, ensure_ascii=False, indent=2) 130 | logger.info(f"設定を {config_file} に保存しました。") 131 | except Exception as e: 132 | logger.error(f"設定保存エラー: {e}") 133 | 134 | def apply_clean_patterns(text, patterns): 135 | """ 136 | 与えられたテキストに対して、初期および追加パターンを適用してクリーニングを行う 137 | """ 138 | text = text.strip() 139 | # 初期パターン適用 140 | for pattern in patterns.get("initial", []): 141 | text = re.sub(pattern, "", text, flags=re.IGNORECASE | re.MULTILINE | re.DOTALL).strip() 142 | # 追加パターン適用 143 | for pattern in patterns.get("additional", []): 144 | text = re.sub(pattern, "", text, flags=re.IGNORECASE | re.MULTILINE | re.DOTALL).strip() 145 | 146 | # 特殊文字削除処理: 改行やいくつかの記号をまとめてカンマに変換 147 | text = re.sub(r'[・\-•**\n] ?', ', ', text, flags=re.MULTILINE | re.DOTALL) 148 | 149 | # 不要な空白や行頭にある読点を調整 150 | parts = [part.strip() for part in text.split(',') if part.strip() and not part.startswith('、')] 151 | return ', '.join(parts) 152 | 153 | # ----------------- GUIウィジェット ----------------- 154 | class DropListWidget(QListWidget): 155 | """ 156 | ドラッグ&ドロップで画像ファイルを追加できるリストウィジェット 157 | """ 158 | files_dropped = Signal(list) 159 | 160 | def __init__(self, parent=None): 161 | super().__init__(parent) 162 | self.setAcceptDrops(True) 163 | self.setIconSize(QSize(100, 100)) 164 | self.setViewMode(QListWidget.IconMode) 165 | self.setResizeMode(QListWidget.Adjust) 166 | self.setSpacing(10) 167 | self.setDragDropMode(QListWidget.InternalMove) 168 | 169 | def dragEnterEvent(self, event): 170 | if event.mimeData().hasUrls(): 171 | event.acceptProposedAction() 172 | else: 173 | super().dragEnterEvent(event) 174 | 175 | def dragMoveEvent(self, event): 176 | if event.mimeData().hasUrls(): 177 | event.acceptProposedAction() 178 | else: 179 | super().dragMoveEvent(event) 180 | 181 | def dropEvent(self, event): 182 | if event.mimeData().hasUrls(): 183 | event.setDropAction(Qt.CopyAction) 184 | event.accept() 185 | file_paths = [url.toLocalFile() for url in event.mimeData().urls()] 186 | self.files_dropped.emit(file_paths) 187 | else: 188 | super().dropEvent(event) 189 | 190 | class ImagePreviewWidget(QWidget): 191 | """ 192 | 画像のプレビュー表示ウィジェット 193 | """ 194 | def __init__(self, parent=None): 195 | super().__init__(parent) 196 | self.image_path = None 197 | self.pixmap = None 198 | layout = QVBoxLayout(self) 199 | layout.setContentsMargins(0, 0, 0, 0) 200 | 201 | self.image_label = QLabel() 202 | self.image_label.setAlignment(Qt.AlignCenter) 203 | self.image_label.setMinimumSize(300, 300) 204 | self.image_label.setStyleSheet("background-color: #2a2a2a; border-radius: 5px;") 205 | layout.addWidget(self.image_label) 206 | 207 | def set_image(self, image_path): 208 | """ 209 | 指定されたパスの画像をラベルに表示する 210 | HEICなどの特殊フォーマットはPILで開いてから変換 211 | """ 212 | self.image_path = image_path 213 | if not image_path or not Path(image_path).exists(): 214 | self.image_label.setText("画像なし") 215 | self.pixmap = None 216 | return 217 | try: 218 | # まずPILで画像を開く 219 | with Image.open(image_path) as img: 220 | if img.mode not in ('RGB', 'RGBA'): 221 | img = img.convert('RGBA') 222 | # PILイメージをQImageに変換 223 | qimg = ImageQt.ImageQt(img) 224 | self.pixmap = QPixmap.fromImage(qimg) 225 | if self.pixmap.isNull(): 226 | self.image_label.setText("画像読み込み失敗") 227 | return 228 | self.update_pixmap() 229 | except Exception as e: 230 | logger.error(f"画像プレビューエラー: {str(e)}") 231 | self.image_label.setText(f"エラー: {str(e)}") 232 | 233 | def update_pixmap(self): 234 | """ 235 | ウィジェットのサイズに合わせて画像をリサイズして表示する 236 | """ 237 | if self.pixmap and not self.pixmap.isNull(): 238 | scaled_pixmap = self.pixmap.scaled( 239 | self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation 240 | ) 241 | self.image_label.setPixmap(scaled_pixmap) 242 | 243 | def resizeEvent(self, event): 244 | """ 245 | ウィジェットのリサイズイベント時に呼び出され、画像サイズを更新 246 | """ 247 | self.update_pixmap() 248 | super().resizeEvent(event) 249 | 250 | class TagDisplayWidget(QWidget): 251 | """ 252 | 分析結果(タグ)を表示するためのテキスト表示ウィジェット 253 | """ 254 | def __init__(self, parent=None): 255 | super().__init__(parent) 256 | layout = QVBoxLayout(self) 257 | layout.setContentsMargins(0, 0, 0, 0) 258 | 259 | self.text_display = QTextEdit() 260 | self.text_display.setReadOnly(True) 261 | self.text_display.setMinimumHeight(100) 262 | self.text_display.setStyleSheet("background-color: #ffffff; border-radius: 5px; color: black;") 263 | layout.addWidget(self.text_display) 264 | 265 | def set_tags(self, tags_text): 266 | """ 267 | 表示用テキストをセットする 268 | """ 269 | self.text_display.setText(tags_text) 270 | 271 | class ImageListItem(QListWidgetItem): 272 | """ 273 | 画像ファイルの一覧に表示する各アイテム 274 | """ 275 | def __init__(self, image_path, parent=None): 276 | super().__init__(parent) 277 | self.image_path = Path(image_path) 278 | self.setText(self.image_path.name) 279 | self.setToolTip(str(self.image_path)) 280 | try: 281 | with Image.open(image_path) as img: 282 | # RGBモードに変換して処理 283 | if img.mode not in ('RGB', 'RGBA'): 284 | img = img.convert('RGBA') 285 | img.thumbnail((100, 100)) 286 | qimg = ImageQt.ImageQt(img) 287 | self.setIcon(QIcon(QPixmap.fromImage(qimg))) 288 | self.setSizeHint(QSize(120, 120)) 289 | except Exception as e: 290 | logger.error(f"サムネイル生成エラー: {str(e)}") 291 | self.setIcon(QIcon()) 292 | 293 | # ----------------- 画像分析関連 ----------------- 294 | class ImageAnalyzer: 295 | """ 296 | 画像分析のためのクラス 297 | """ 298 | def __init__( 299 | self, 300 | model="gemma3:27b", 301 | use_japanese=False, 302 | detail_level="standard", 303 | custom_prompt=None, 304 | clean_custom_response=True, 305 | api_url="http://localhost:11434/api/generate", 306 | clean_patterns=None 307 | ): 308 | """ 309 | :param model: 使用するモデル名 310 | :param use_japanese: Trueの場合、日本語で説明させる 311 | :param detail_level: 'brief', 'standard', 'detailed' の3段階 312 | :param custom_prompt: カスタムプロンプト文字列 313 | :param clean_custom_response: Trueの場合、余計な前置きを自動的に削除 314 | :param api_url: APIエンドポイントのURL 315 | :param clean_patterns: 削除・置換パターン辞書 316 | """ 317 | self.model = model 318 | self.use_japanese = use_japanese 319 | self.detail_level = detail_level 320 | self.custom_prompt = custom_prompt 321 | self.clean_custom_response = clean_custom_response 322 | self.api_url = api_url 323 | # HEIC対応のため、拡張子を追加 324 | self.supported_formats = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.avif'} 325 | self.clean_patterns = clean_patterns or { 326 | "initial": default_initial_patterns, 327 | "additional": default_additional_patterns 328 | } 329 | 330 | def encode_image(self, image_path): 331 | """ 332 | Pillowで画像を開き、Base64にエンコードして返す 333 | """ 334 | try: 335 | with Image.open(image_path) as img: 336 | # RGBモードに変換して処理 337 | if img.mode not in ('RGB', 'RGBA'): 338 | img = img.convert('RGBA') 339 | img_buffer = io.BytesIO() 340 | save_format = img.format if img.format else "PNG" 341 | if save_format.upper() in ["HEIF", "WEBP"]: 342 | save_format = "PNG" 343 | img.save(img_buffer, format=save_format) 344 | return base64.b64encode(img_buffer.getvalue()).decode('utf-8') 345 | except Exception as e: 346 | logger.error(f"画像エンコードエラー: {str(e)}") 347 | raise 348 | 349 | def get_prompt(self): 350 | """ 351 | カスタムプロンプトがあればそれを使用し、 352 | なければ detail_level と use_japanese に応じたデフォルトを返す 353 | """ 354 | if self.custom_prompt: 355 | prompt = self.custom_prompt.strip() 356 | if self.clean_custom_response: 357 | if self.use_japanese: 358 | prompt += " 主要な要素や行動に焦点を当て、余計な前置きは不要です。" 359 | else: 360 | prompt += " Focus on key elements and actions, and omit unnecessary introductory phrases." 361 | return prompt 362 | 363 | # デフォルトのプロンプト 364 | if self.use_japanese: 365 | if self.detail_level == "brief": 366 | return "この画像を1文で簡潔に説明してください。余計な前置きは不要です。" 367 | elif self.detail_level == "standard": 368 | return "この画像を2〜3文で説明してください。主要な要素や行動に焦点を当て、余計な前置きは不要です。" 369 | else: 370 | return "この画像を4〜5文で詳しく説明してください。視覚的な要素、行動、雰囲気などを含めて説明し、余計な前置きは不要です。" 371 | else: 372 | if self.detail_level == "brief": 373 | return "Describe this image in a single concise sentence, without any introductory phrases." 374 | elif self.detail_level == "standard": 375 | return "Describe this image in 2-3 sentences, focusing on key elements and actions. No introductory phrases." 376 | else: 377 | return "Describe this image in 4-5 sentences, including visual elements, actions, and atmosphere. No introductory phrases." 378 | 379 | def clean_response_text(self, response): 380 | """ 381 | 得られたレスポンステキストを設定されたパターンに基づいてクリーンアップする 382 | """ 383 | return apply_clean_patterns(response, self.clean_patterns) 384 | 385 | def analyze_image(self, image_path): 386 | """ 387 | 画像をAPIに送信して分析し、テキスト(タグ)を返す 388 | """ 389 | try: 390 | base64_image = self.encode_image(image_path) 391 | payload = { 392 | "model": self.model, 393 | "prompt": self.get_prompt(), 394 | "stream": False, 395 | "images": [base64_image] 396 | } 397 | response = requests.post(self.api_url, json=payload) 398 | if response.status_code != 200: 399 | logger.error(f"APIエラー: status_code={response.status_code}, text={response.text}") 400 | raise Exception(f"APIエラー: {response.status_code} - {response.text}") 401 | 402 | result = response.json() 403 | response_text = result.get('response', 'No analysis available') 404 | if not self.clean_custom_response: 405 | return response_text 406 | else: 407 | return self.clean_response_text(response_text) 408 | except requests.exceptions.RequestException as e: 409 | logger.error(f"API通信エラー: {str(e)}") 410 | raise 411 | except Exception as e: 412 | logger.error(f"画像分析エラー: {str(e)}") 413 | raise 414 | 415 | class AnalysisWorker(QThread): 416 | """ 417 | 画像分析処理を別スレッドで実行するためのワーカークラス 418 | """ 419 | progress_updated = Signal(float) 420 | analysis_complete = Signal(int, int) 421 | analysis_error = Signal(str) 422 | image_analyzed = Signal(str, str) 423 | 424 | def __init__(self, analyzer, image_paths): 425 | super().__init__() 426 | self.analyzer = analyzer 427 | self.image_paths = image_paths 428 | self.stop_requested = False 429 | 430 | def run(self): 431 | """ 432 | 指定された画像リストを順番に分析し、結果を.txtファイルに書き出す 433 | """ 434 | total = len(self.image_paths) 435 | if total == 0: 436 | return 437 | processed = 0 438 | errors = 0 439 | for image_path in self.image_paths: 440 | if self.stop_requested: 441 | break 442 | try: 443 | logger.info(f"処理中: {Path(image_path).name}") 444 | result = self.analyzer.analyze_image(image_path) 445 | text_path = Path(image_path).with_suffix('.txt') 446 | with open(text_path, 'w', encoding='utf-8') as f: 447 | f.write(result) 448 | self.image_analyzed.emit(image_path, result) 449 | processed += 1 450 | except Exception as e: 451 | logger.error(f"分析エラー: {str(e)}") 452 | errors += 1 453 | progress = (processed + errors) / total * 100 454 | self.progress_updated.emit(progress) 455 | self.analysis_complete.emit(processed, errors) 456 | 457 | def stop(self): 458 | """ 459 | 分析処理を停止するフラグを立てる 460 | """ 461 | self.stop_requested = True 462 | 463 | class FileSearchWorker(QThread): 464 | """ 465 | テキストファイル内のタグ検索を別スレッドで実行するためのワーカークラス 466 | """ 467 | progress_updated = Signal(float) 468 | search_complete = Signal(list) 469 | search_error = Signal(str) 470 | 471 | def __init__(self, source_dir, search_terms, search_mode="OR", use_regex=False): 472 | super().__init__() 473 | self.source_dir = source_dir 474 | self.search_terms = search_terms 475 | self.search_mode = search_mode 476 | self.use_regex = use_regex 477 | self.stop_requested = False 478 | 479 | def run(self): 480 | """ 481 | 指定フォルダ直下の画像ファイルと同名の.txtファイルを検索し、 482 | 検索キーワードに合致するファイルパスを収集する 483 | """ 484 | try: 485 | source_path = Path(self.source_dir) 486 | if not source_path.exists(): 487 | self.search_error.emit(f"ソースフォルダが存在しません: {self.source_dir}") 488 | return 489 | 490 | supported_formats = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.avif'} 491 | image_files = [] 492 | for ext in supported_formats: 493 | image_files.extend(source_path.glob(f"*{ext}")) 494 | image_files.extend(source_path.glob(f"*{ext.upper()}")) 495 | 496 | total_files = len(image_files) 497 | if total_files == 0: 498 | self.search_error.emit("画像ファイルが見つかりません") 499 | return 500 | 501 | matched_files = set() 502 | processed = 0 503 | for image_path in image_files: 504 | if self.stop_requested: 505 | break 506 | text_path = image_path.with_suffix('.txt') 507 | if text_path.exists(): 508 | try: 509 | with open(text_path, 'r', encoding='utf-8') as f: 510 | content = f.read() 511 | if self._match_content(content): 512 | matched_files.add(str(image_path)) 513 | except Exception as e: 514 | logger.error(f"ファイル読み込みエラー: {str(e)}") 515 | processed += 1 516 | self.progress_updated.emit(processed / total_files * 100) 517 | 518 | self.search_complete.emit(list(matched_files)) 519 | except Exception as e: 520 | self.search_error.emit(f"検索エラー: {str(e)}") 521 | 522 | def _match_content(self, content): 523 | """ 524 | 検索キーワードのOR/ANDマッチを行う(正規表現オプション対応) 525 | """ 526 | if not self.search_terms: 527 | return False 528 | 529 | matches = [] 530 | for term in self.search_terms: 531 | if not term: 532 | continue 533 | if self.use_regex: 534 | try: 535 | pattern = re.compile(term, re.IGNORECASE) 536 | match = bool(pattern.search(content)) 537 | except re.error: 538 | match = term.lower() in content.lower() 539 | else: 540 | match = term.lower() in content.lower() 541 | matches.append(match) 542 | 543 | if self.search_mode == "AND": 544 | return all(matches) 545 | else: 546 | return any(matches) 547 | 548 | def stop(self): 549 | """ 550 | 検索処理を停止するフラグを立てる 551 | """ 552 | self.stop_requested = True 553 | 554 | class FileOperationWorker(QThread): 555 | """ 556 | ファイルのコピー・移動処理を別スレッドで実行するためのワーカークラス 557 | """ 558 | progress_updated = Signal(float) 559 | operation_complete = Signal(int) 560 | operation_error = Signal(str) 561 | 562 | def __init__(self, file_paths, target_dir, operation="copy"): 563 | super().__init__() 564 | self.file_paths = file_paths 565 | self.target_dir = target_dir 566 | self.operation = operation 567 | self.stop_requested = False 568 | 569 | def run(self): 570 | """ 571 | 選択されたファイルをコピーまたは移動し、対応する.txtファイルも同様にコピー/移動する 572 | """ 573 | try: 574 | target_path = Path(self.target_dir) 575 | if not target_path.exists(): 576 | target_path.mkdir(parents=True, exist_ok=True) 577 | 578 | total_files = len(self.file_paths) 579 | if total_files == 0: 580 | return 581 | 582 | processed = 0 583 | for file_path in self.file_paths: 584 | if self.stop_requested: 585 | break 586 | src_path = Path(file_path) 587 | if not src_path.exists(): 588 | continue 589 | dst_path = target_path / src_path.name 590 | 591 | # 同名ファイルがある場合は連番を付ける 592 | if dst_path.exists(): 593 | base_name = dst_path.stem 594 | extension = dst_path.suffix 595 | counter = 1 596 | while dst_path.exists(): 597 | new_name = f"{base_name}_{counter}{extension}" 598 | dst_path = target_path / new_name 599 | counter += 1 600 | 601 | try: 602 | # 画像コピー/移動 603 | if self.operation == "copy": 604 | shutil.copy2(src_path, dst_path) 605 | # テキストファイルも同様に 606 | text_path = src_path.with_suffix('.txt') 607 | if text_path.exists(): 608 | text_target = dst_path.with_suffix('.txt') 609 | shutil.copy2(text_path, text_target) 610 | else: 611 | shutil.move(src_path, dst_path) 612 | text_path = src_path.with_suffix('.txt') 613 | if text_path.exists(): 614 | text_target = dst_path.with_suffix('.txt') 615 | shutil.move(text_path, text_target) 616 | processed += 1 617 | except Exception as e: 618 | logger.error(f"ファイル操作エラー: {str(e)}") 619 | 620 | self.progress_updated.emit(processed / total_files * 100) 621 | self.operation_complete.emit(processed) 622 | except Exception as e: 623 | self.operation_error.emit(f"ファイル操作エラー: {str(e)}") 624 | 625 | def stop(self): 626 | """ 627 | ファイル操作処理を停止するフラグを立てる 628 | """ 629 | self.stop_requested = True 630 | 631 | class ChatWorker(QThread): 632 | """ 633 | AIチャットAPIへの通信を非同期で実行するワーカー 634 | """ 635 | result_ready = Signal(dict) 636 | error_occurred = Signal(str) 637 | 638 | def __init__(self, api_url, payload): 639 | super().__init__() 640 | self.api_url = api_url 641 | self.payload = payload 642 | 643 | def run(self): 644 | """ 645 | APIにPOSTリクエストを送り、結果を受け取る 646 | """ 647 | try: 648 | response = requests.post(self.api_url, json=self.payload) 649 | if response.status_code != 200: 650 | raise Exception(f"HTTP {response.status_code} Error: {response.text}") 651 | result = response.json() 652 | self.result_ready.emit(result) 653 | except Exception as e: 654 | self.error_occurred.emit(str(e)) 655 | 656 | # ----------------- 削除パターンテストダイアログ ----------------- 657 | class PatternTestDialog(QDialog): 658 | """ 659 | ユーザーが入力した削除パターンをサンプルテキストに対してテストできるダイアログ 660 | """ 661 | def __init__(self, patterns, parent=None): 662 | super().__init__(parent) 663 | self.setWindowTitle("削除パターンテスト") 664 | self.resize(600, 400) 665 | self.patterns = patterns 666 | 667 | layout = QVBoxLayout(self) 668 | 669 | sample_label = QLabel("サンプルテキスト:") 670 | layout.addWidget(sample_label) 671 | 672 | self.sample_edit = QTextEdit() 673 | self.sample_edit.setPlainText("ここにサンプルテキストを入力してください。") 674 | layout.addWidget(self.sample_edit) 675 | 676 | test_button = QPushButton("テスト実行") 677 | test_button.clicked.connect(self.run_test) 678 | layout.addWidget(test_button) 679 | 680 | result_label = QLabel("テスト結果:") 681 | layout.addWidget(result_label) 682 | 683 | self.result_display = QTextEdit() 684 | self.result_display.setReadOnly(True) 685 | layout.addWidget(self.result_display) 686 | 687 | close_button = QPushButton("閉じる") 688 | close_button.clicked.connect(self.accept) 689 | layout.addWidget(close_button) 690 | 691 | def run_test(self): 692 | """ 693 | サンプルテキストに現在のパターンを適用した結果を表示 694 | """ 695 | sample_text = self.sample_edit.toPlainText() 696 | result = apply_clean_patterns(sample_text, self.patterns) 697 | self.result_display.setPlainText(result) 698 | 699 | # ----------------- 削除パターン設定ダイアログ ----------------- 700 | class DeletionPatternDialog(QDialog): 701 | """ 702 | 削除パターンの設定を行うダイアログ 703 | """ 704 | def __init__(self, parent=None, current_patterns=None): 705 | super().__init__(parent) 706 | self.setWindowTitle("削除パターン設定") 707 | self.setMinimumWidth(600) 708 | self.current_patterns = current_patterns or { 709 | "initial": default_initial_patterns, 710 | "additional": default_additional_patterns 711 | } 712 | layout = QVBoxLayout(self) 713 | 714 | init_group = QGroupBox("初期クリーニングパターン(1行1パターン)") 715 | init_layout = QVBoxLayout() 716 | self.initial_patterns_edit = QTextEdit() 717 | self.initial_patterns_edit.setPlainText("\n".join(self.current_patterns.get("initial", default_initial_patterns))) 718 | init_layout.addWidget(self.initial_patterns_edit) 719 | init_group.setLayout(init_layout) 720 | layout.addWidget(init_group) 721 | 722 | add_group = QGroupBox("追加クリーニングパターン(1行1パターン)") 723 | add_layout = QVBoxLayout() 724 | self.additional_patterns_edit = QTextEdit() 725 | self.additional_patterns_edit.setPlainText("\n".join(self.current_patterns.get("additional", default_additional_patterns))) 726 | add_layout.addWidget(self.additional_patterns_edit) 727 | add_group.setLayout(add_layout) 728 | layout.addWidget(add_group) 729 | 730 | # 正規表現の情報ボタン 731 | self.regex_info_button = QPushButton("正規表現について") 732 | self.regex_info_button.clicked.connect(self.show_regex_info) 733 | layout.addWidget(self.regex_info_button) 734 | 735 | # パターンテストボタン 736 | self.test_pattern_button = QPushButton("パターンテスト") 737 | self.test_pattern_button.clicked.connect(self.open_pattern_test) 738 | layout.addWidget(self.test_pattern_button) 739 | 740 | # 設定ファイルのロード/セーブボタン 741 | file_button_layout = QHBoxLayout() 742 | self.load_button = QPushButton("設定ファイルを読み込み") 743 | self.save_button = QPushButton("設定ファイルの保存先選択") 744 | self.load_button.clicked.connect(self.load_patterns) 745 | self.save_button.clicked.connect(self.save_patterns) 746 | file_button_layout.addWidget(self.load_button) 747 | file_button_layout.addWidget(self.save_button) 748 | layout.addLayout(file_button_layout) 749 | 750 | close_layout = QHBoxLayout() 751 | self.close_button = QPushButton("閉じる") 752 | self.close_button.clicked.connect(self.accept) 753 | close_layout.addStretch() 754 | close_layout.addWidget(self.close_button) 755 | layout.addLayout(close_layout) 756 | 757 | def show_regex_info(self): 758 | """ 759 | 正規表現の簡易的なガイドを表示 760 | """ 761 | info = ( 762 | "【正規表現の基本例】\n" 763 | " - '^abc' : 文字列の先頭が 'abc' である場合にマッチ\n" 764 | " - 'xyz$' : 文字列の末尾が 'xyz' である場合にマッチ\n" 765 | " - '[0-9]+' : 1文字以上の数字にマッチ\n" 766 | " - '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}' : メールアドレスの簡易マッチ\n" 767 | " - 'ab|cd' : 'ab'または'cd'にマッチ\n" 768 | " - '\\s+' : 1文字以上の空白文字にマッチ\n" 769 | " - '.*' : 任意の文字が0文字以上連続(貪欲マッチ)\n" 770 | "【特別な記号例】\n" 771 | " - '.' : 任意の1文字(改行を除く; re.DOTALLで改行含む)\n" 772 | " - '*' : 直前の要素を0回以上繰り返し\n" 773 | " - '+' : 直前の要素を1回以上繰り返し\n" 774 | " - '?' : 直前の要素を0回または1回\n" 775 | " - '(?m)' : マルチラインモード(^と$が行頭・行末にマッチ)\n" 776 | " - '(?s)' : DOTALLモード('.'が改行にもマッチ)\n" 777 | ) 778 | QMessageBox.information(self, "正規表現について", info) 779 | 780 | def open_pattern_test(self): 781 | """ 782 | パターンテストダイアログを表示する 783 | """ 784 | current_patterns = { 785 | "initial": [line.strip() for line in self.initial_patterns_edit.toPlainText().splitlines() if line.strip()], 786 | "additional": [line.strip() for line in self.additional_patterns_edit.toPlainText().splitlines() if line.strip()] 787 | } 788 | dialog = PatternTestDialog(current_patterns, self) 789 | dialog.exec() 790 | 791 | def load_patterns(self): 792 | """ 793 | JSONファイルから削除パターンをロードする 794 | """ 795 | file_path, _ = QFileDialog.getOpenFileName(self, "設定ファイルを選択", "", "JSON Files (*.json)") 796 | if file_path: 797 | config = load_app_config(file_path) 798 | patterns = config.get("clean_patterns", {}) 799 | init = patterns.get("initial", default_initial_patterns) 800 | add = patterns.get("additional", default_additional_patterns) 801 | self.initial_patterns_edit.setPlainText("\n".join(init)) 802 | self.additional_patterns_edit.setPlainText("\n".join(add)) 803 | QMessageBox.information(self, "ロード完了", "削除パターンをロードしました。") 804 | 805 | def save_patterns(self): 806 | """ 807 | 現在の削除パターンをJSONファイルに保存する 808 | """ 809 | file_path, _ = QFileDialog.getSaveFileName(self, "設定ファイルの保存先を選択", "", "JSON Files (*.json)") 810 | if file_path: 811 | init = [line.strip() for line in self.initial_patterns_edit.toPlainText().splitlines() if line.strip()] 812 | add = [line.strip() for line in self.additional_patterns_edit.toPlainText().splitlines() if line.strip()] 813 | config = load_app_config(file_path) 814 | config["clean_patterns"] = {"initial": init, "additional": add} 815 | save_app_config(config, file_path) 816 | QMessageBox.information(self, "保存完了", "削除パターンを保存しました。") 817 | 818 | def get_patterns(self): 819 | """ 820 | 現在の削除パターンを辞書形式で取得 821 | """ 822 | init = [line.strip() for line in self.initial_patterns_edit.toPlainText().splitlines() if line.strip()] 823 | add = [line.strip() for line in self.additional_patterns_edit.toPlainText().splitlines() if line.strip()] 824 | return {"initial": init, "additional": add} 825 | 826 | # ----------------- 詳細設定ダイアログ ----------------- 827 | class SettingsDialog(QDialog): 828 | """ 829 | 画像タグ付けの詳細設定を行うダイアログ 830 | """ 831 | def __init__(self, parent=None): 832 | super().__init__(parent) 833 | self.setWindowTitle("詳細設定") 834 | self.setMinimumWidth(500) 835 | layout = QVBoxLayout(self) 836 | 837 | # カスタムプロンプト設定 838 | prompt_group = QGroupBox("カスタムプロンプト") 839 | prompt_layout = QVBoxLayout() 840 | self.custom_prompt = QTextEdit() 841 | self.custom_prompt.setPlaceholderText("カスタムプロンプトを入力(空白ならデフォルト使用)") 842 | self.custom_prompt.setMinimumHeight(100) 843 | self.clean_response = QCheckBox("前置きや余計な表現を削除") 844 | self.clean_response.setChecked(True) 845 | prompt_layout.addWidget(self.custom_prompt) 846 | prompt_layout.addWidget(self.clean_response) 847 | prompt_group.setLayout(prompt_layout) 848 | layout.addWidget(prompt_group) 849 | 850 | # 詳細度設定 851 | detail_group = QGroupBox("説明の詳細度(カスタムプロンプト使用時は無効)") 852 | detail_layout = QHBoxLayout() 853 | self.detail_level = "standard" 854 | self.brief_radio = QRadioButton("簡潔(1文)") 855 | self.standard_radio = QRadioButton("標準(2-3文)") 856 | self.detailed_radio = QRadioButton("詳細(4-5文)") 857 | self.standard_radio.setChecked(True) 858 | self.brief_radio.toggled.connect(self._update_detail_level) 859 | self.standard_radio.toggled.connect(self._update_detail_level) 860 | self.detailed_radio.toggled.connect(self._update_detail_level) 861 | detail_layout.addWidget(self.brief_radio) 862 | detail_layout.addWidget(self.standard_radio) 863 | detail_layout.addWidget(self.detailed_radio) 864 | detail_group.setLayout(detail_layout) 865 | layout.addWidget(detail_group) 866 | 867 | file_button_layout = QHBoxLayout() 868 | self.load_config_button = QPushButton("設定ファイルを読み込み") 869 | self.save_config_button = QPushButton("設定ファイルの保存先選択") 870 | self.load_config_button.clicked.connect(self.load_config) 871 | self.save_config_button.clicked.connect(self.save_config) 872 | file_button_layout.addWidget(self.load_config_button) 873 | file_button_layout.addWidget(self.save_config_button) 874 | layout.addLayout(file_button_layout) 875 | 876 | button_layout = QHBoxLayout() 877 | self.ok_button = QPushButton("OK") 878 | self.cancel_button = QPushButton("キャンセル") 879 | self.ok_button.clicked.connect(self.accept) 880 | self.cancel_button.clicked.connect(self.reject) 881 | button_layout.addStretch() 882 | button_layout.addWidget(self.ok_button) 883 | button_layout.addWidget(self.cancel_button) 884 | layout.addLayout(button_layout) 885 | 886 | self.setLayout(layout) 887 | 888 | def _update_detail_level(self): 889 | """ 890 | ラジオボタンの選択状態を detail_level に反映 891 | """ 892 | if self.brief_radio.isChecked(): 893 | self.detail_level = "brief" 894 | elif self.standard_radio.isChecked(): 895 | self.detail_level = "standard" 896 | else: 897 | self.detail_level = "detailed" 898 | 899 | def load_config(self): 900 | """ 901 | 設定ファイル(JSON)から読み込んでフォームに適用 902 | """ 903 | file_path, _ = QFileDialog.getOpenFileName(self, "設定ファイルを選択", "", "JSON Files (*.json)") 904 | if file_path: 905 | config = load_app_config(file_path) 906 | if "custom_prompt" in config: 907 | self.custom_prompt.setPlainText(config["custom_prompt"]) 908 | if "clean_response" in config: 909 | self.clean_response.setChecked(config["clean_response"]) 910 | if "detail_level" in config: 911 | self.detail_level = config["detail_level"] 912 | if self.detail_level == "brief": 913 | self.brief_radio.setChecked(True) 914 | elif self.detail_level == "standard": 915 | self.standard_radio.setChecked(True) 916 | else: 917 | self.detailed_radio.setChecked(True) 918 | QMessageBox.information(self, "ロード完了", "設定ファイルをロードしました。") 919 | 920 | def save_config(self): 921 | """ 922 | 現在の設定をJSONファイルとして保存 923 | """ 924 | file_path, _ = QFileDialog.getSaveFileName(self, "設定ファイルの保存先を選択", "", "JSON Files (*.json)") 925 | if file_path: 926 | config = self.get_settings() 927 | save_app_config(config, file_path) 928 | QMessageBox.information(self, "保存完了", "設定を保存しました。") 929 | 930 | def get_settings(self): 931 | """ 932 | 現在のフォーム入力から設定を辞書形式で取得 933 | """ 934 | return { 935 | "custom_prompt": self.custom_prompt.toPlainText().strip(), 936 | "clean_response": self.clean_response.isChecked(), 937 | "detail_level": self.detail_level 938 | } 939 | 940 | def set_settings(self, settings): 941 | """ 942 | 外部から与えられた設定をフォームに反映 943 | """ 944 | if "custom_prompt" in settings: 945 | self.custom_prompt.setPlainText(settings["custom_prompt"]) 946 | if "clean_response" in settings: 947 | self.clean_response.setChecked(settings["clean_response"]) 948 | if "detail_level" in settings: 949 | self.detail_level = settings["detail_level"] 950 | if self.detail_level == "brief": 951 | self.brief_radio.setChecked(True) 952 | elif self.detail_level == "standard": 953 | self.standard_radio.setChecked(True) 954 | else: 955 | self.detailed_radio.setChecked(True) 956 | 957 | # ----------------- AIチャットタブ ----------------- 958 | class ChatTab(QWidget): 959 | """ 960 | AIチャット用タブ 961 | """ 962 | def __init__(self, parent=None, get_api_url_func=None, get_model_func=None): 963 | super().__init__(parent) 964 | self.get_api_url_func = get_api_url_func 965 | self.get_model_func = get_model_func 966 | self.waiting_message_displayed = False 967 | 968 | layout = QVBoxLayout(self) 969 | 970 | self.chat_display = QTextEdit() 971 | self.chat_display.setReadOnly(True) 972 | layout.addWidget(self.chat_display) 973 | 974 | input_layout = QHBoxLayout() 975 | self.input_edit = QLineEdit() 976 | # Shift+Enter で送信できるようにイベントをフック 977 | self.input_edit.installEventFilter(self) 978 | self.send_button = QPushButton("送信") 979 | self.send_button.clicked.connect(self.send_message) 980 | input_layout.addWidget(self.input_edit) 981 | input_layout.addWidget(self.send_button) 982 | layout.addLayout(input_layout) 983 | 984 | self.setLayout(layout) 985 | 986 | def eventFilter(self, source, event): 987 | """ 988 | Shift+Enter でメッセージ送信 989 | """ 990 | if source == self.input_edit and event.type() == QEvent.KeyPress: 991 | if event.key() == Qt.Key_Return and (event.modifiers() & Qt.ShiftModifier): 992 | self.send_message() 993 | return True 994 | return super().eventFilter(source, event) 995 | 996 | def send_message(self): 997 | """ 998 | 入力メッセージを送信し、AIの応答を受け取る 999 | """ 1000 | message = self.input_edit.text().strip() 1001 | if not message: 1002 | return 1003 | self.append_chat("ユーザー", message) 1004 | self.input_edit.clear() 1005 | 1006 | api_url = self.get_api_url_func() if self.get_api_url_func else "http://localhost:11434/api/generate" 1007 | model_name = self.get_model_func() if self.get_model_func else "chat" 1008 | 1009 | payload = { 1010 | "model": model_name, 1011 | "prompt": message, 1012 | "stream": False, 1013 | "images": [] 1014 | } 1015 | 1016 | self.send_button.setEnabled(False) 1017 | self.append_chat("システム", "応答を待っています...") 1018 | self.waiting_message_displayed = True 1019 | 1020 | self.chat_worker = ChatWorker(api_url, payload) 1021 | self.chat_worker.result_ready.connect(self.handle_chat_result) 1022 | self.chat_worker.error_occurred.connect(self.handle_chat_error) 1023 | self.chat_worker.finished.connect(lambda: self.send_button.setEnabled(True)) 1024 | self.chat_worker.start() 1025 | 1026 | def handle_chat_result(self, result): 1027 | """ 1028 | AIからの応答を受け取り、チャット欄に表示 1029 | """ 1030 | self.remove_waiting_message() 1031 | reply = result.get("response", "No reply") 1032 | self.append_chat("AI", reply) 1033 | 1034 | def handle_chat_error(self, error): 1035 | """ 1036 | エラー時にメッセージを表示 1037 | """ 1038 | self.remove_waiting_message() 1039 | self.append_chat("エラー", f"サーバへの接続またはAI処理に失敗しました: {error}") 1040 | 1041 | def remove_waiting_message(self): 1042 | """ 1043 | "応答を待っています..." の行を削除する 1044 | """ 1045 | if self.waiting_message_displayed: 1046 | text = self.chat_display.toPlainText() 1047 | lines = text.split("\n") 1048 | if lines and lines[-1].startswith("システム: 応答を待っています"): 1049 | lines.pop() 1050 | self.chat_display.setPlainText("\n".join(lines)) 1051 | self.waiting_message_displayed = False 1052 | 1053 | def append_chat(self, sender, text): 1054 | """ 1055 | チャット欄にメッセージを追加し、末尾までスクロール 1056 | """ 1057 | self.chat_display.append(f"{sender}: {text}") 1058 | self.chat_display.moveCursor(QTextCursor.End) 1059 | 1060 | # ----------------- 画像タグ付けタブ ----------------- 1061 | class ImageTaggingTab(QWidget): 1062 | """ 1063 | 画像タグ付け機能を提供するタブ 1064 | """ 1065 | def __init__(self, parent=None): 1066 | super().__init__(parent) 1067 | loaded_config = load_app_config() 1068 | # 設定を辞書にまとめて保持 1069 | self.settings = { 1070 | "model": loaded_config.get("model", "gemma3:27b"), 1071 | "api_url": loaded_config.get("api_url", "http://localhost:11434/api/generate"), 1072 | "use_japanese": loaded_config.get("use_japanese", False), 1073 | "custom_prompt": loaded_config.get("custom_prompt", ""), 1074 | "clean_response": loaded_config.get("clean_response", True), 1075 | "detail_level": loaded_config.get("detail_level", "standard"), 1076 | "clean_patterns": loaded_config.get("clean_patterns", { 1077 | "initial": default_initial_patterns, 1078 | "additional": default_additional_patterns 1079 | }) 1080 | } 1081 | self.analyzer = None 1082 | self.worker = None 1083 | self.init_ui() 1084 | 1085 | def init_ui(self): 1086 | """ 1087 | タブ内のUIを初期化 1088 | """ 1089 | main_layout = QVBoxLayout(self) 1090 | 1091 | # ツールバー 1092 | toolbar = QToolBar() 1093 | toolbar.setIconSize(QSize(16, 16)) 1094 | toolbar.setMovable(False) 1095 | 1096 | model_label = QLabel("Ollamaモデル:") 1097 | toolbar.addWidget(model_label) 1098 | 1099 | self.model_combo = QComboBox() 1100 | self.model_combo.addItems(["gemma3:27b", "gemma3:4b"]) 1101 | self.model_combo.setEditable(True) 1102 | self.model_combo.setMinimumWidth(150) 1103 | self.model_combo.setCurrentText(self.settings["model"]) 1104 | toolbar.addWidget(self.model_combo) 1105 | toolbar.addSeparator() 1106 | 1107 | url_label = QLabel("Ollama URL:") 1108 | toolbar.addWidget(url_label) 1109 | 1110 | self.url_edit = QLineEdit(self.settings["api_url"]) 1111 | self.url_edit.setMinimumWidth(250) 1112 | toolbar.addWidget(self.url_edit) 1113 | toolbar.addSeparator() 1114 | 1115 | self.japanese_check = QCheckBox("日本語で出力") 1116 | self.japanese_check.setChecked(self.settings["use_japanese"]) 1117 | toolbar.addWidget(self.japanese_check) 1118 | toolbar.addSeparator() 1119 | 1120 | self.settings_button = QPushButton("詳細設定") 1121 | self.settings_button.clicked.connect(self.show_settings) 1122 | toolbar.addWidget(self.settings_button) 1123 | 1124 | main_layout.addWidget(toolbar) 1125 | 1126 | # メインコンテンツ(スプリッターで左右分割) 1127 | main_splitter = QSplitter(Qt.Horizontal) 1128 | 1129 | # 左ペイン(画像リスト) 1130 | left_panel = QWidget() 1131 | left_layout = QVBoxLayout(left_panel) 1132 | left_layout.setContentsMargins(0, 0, 0, 0) 1133 | 1134 | list_label = QLabel("画像リスト") 1135 | list_label.setStyleSheet("font-weight: bold; font-size: 14px;") 1136 | left_layout.addWidget(list_label) 1137 | 1138 | self.image_list = DropListWidget() 1139 | self.image_list.setSelectionMode(QListWidget.ExtendedSelection) 1140 | self.image_list.currentItemChanged.connect(self.on_image_selected) 1141 | self.image_list.files_dropped.connect(self.add_dropped_files) 1142 | left_layout.addWidget(self.image_list) 1143 | 1144 | button_layout = QHBoxLayout() 1145 | self.add_folder_button = QPushButton("フォルダ追加") 1146 | self.add_files_button = QPushButton("ファイル追加") 1147 | self.remove_button = QPushButton("選択削除") 1148 | self.add_folder_button.clicked.connect(self.add_folder) 1149 | self.add_files_button.clicked.connect(self.add_files) 1150 | self.remove_button.clicked.connect(self.remove_selected) 1151 | button_layout.addWidget(self.add_folder_button) 1152 | button_layout.addWidget(self.add_files_button) 1153 | button_layout.addWidget(self.remove_button) 1154 | left_layout.addLayout(button_layout) 1155 | 1156 | # 右ペイン(プレビュー + 結果表示) 1157 | right_panel = QWidget() 1158 | right_layout = QVBoxLayout(right_panel) 1159 | right_layout.setContentsMargins(0, 0, 0, 0) 1160 | 1161 | right_splitter = QSplitter(Qt.Vertical) 1162 | 1163 | preview_widget = QWidget() 1164 | preview_layout = QVBoxLayout(preview_widget) 1165 | preview_layout.setContentsMargins(0, 0, 0, 0) 1166 | 1167 | preview_label = QLabel("画像プレビュー") 1168 | preview_label.setStyleSheet("font-weight: bold; font-size: 14px;") 1169 | preview_layout.addWidget(preview_label) 1170 | 1171 | self.image_preview = ImagePreviewWidget() 1172 | preview_layout.addWidget(self.image_preview) 1173 | 1174 | result_widget = QWidget() 1175 | result_layout = QVBoxLayout(result_widget) 1176 | result_layout.setContentsMargins(0, 0, 0, 0) 1177 | 1178 | result_label = QLabel("分析結果") 1179 | result_label.setStyleSheet("font-weight: bold; font-size: 14px;") 1180 | result_layout.addWidget(result_label) 1181 | 1182 | self.tag_display = TagDisplayWidget() 1183 | result_layout.addWidget(self.tag_display) 1184 | 1185 | right_splitter.addWidget(preview_widget) 1186 | right_splitter.addWidget(result_widget) 1187 | right_splitter.setSizes([300, 300]) 1188 | right_layout.addWidget(right_splitter) 1189 | 1190 | main_splitter.addWidget(left_panel) 1191 | main_splitter.addWidget(right_panel) 1192 | main_splitter.setSizes([300, 500]) 1193 | main_layout.addWidget(main_splitter, 1) 1194 | 1195 | # 下部操作ボタン 1196 | bottom_layout = QHBoxLayout() 1197 | 1198 | status_layout = QVBoxLayout() 1199 | self.status_label = QLabel("準備完了") 1200 | status_layout.addWidget(self.status_label) 1201 | 1202 | self.progress_bar = QProgressBar() 1203 | self.progress_bar.setRange(0, 100) 1204 | self.progress_bar.setValue(0) 1205 | status_layout.addWidget(self.progress_bar) 1206 | 1207 | bottom_layout.addLayout(status_layout, 1) 1208 | 1209 | self.run_button = QPushButton("分析実行") 1210 | self.run_button.setMinimumSize(120, 40) 1211 | self.run_button.setStyleSheet(""" 1212 | QPushButton { background-color: #4CAF50; color: white; font-weight: bold; border-radius: 5px; } 1213 | QPushButton:hover { background-color: #45a049; } 1214 | QPushButton:disabled { background-color: #cccccc; } 1215 | """) 1216 | self.run_button.clicked.connect(self.run_analysis) 1217 | 1218 | self.stop_button = QPushButton("停止") 1219 | self.stop_button.setMinimumSize(120, 40) 1220 | self.stop_button.setStyleSheet(""" 1221 | QPushButton { background-color: #f44336; color: white; font-weight: bold; border-radius: 5px; } 1222 | QPushButton:hover { background-color: #d32f2f; } 1223 | QPushButton:disabled { background-color: #cccccc; } 1224 | """) 1225 | self.stop_button.clicked.connect(self.stop_analysis) 1226 | self.stop_button.setEnabled(False) 1227 | 1228 | bottom_layout.addWidget(self.run_button) 1229 | bottom_layout.addWidget(self.stop_button) 1230 | main_layout.addLayout(bottom_layout) 1231 | 1232 | self.setLayout(main_layout) 1233 | 1234 | def show_settings(self): 1235 | """ 1236 | 詳細設定ダイアログを開き、プロンプトやクリーニング設定を編集する 1237 | """ 1238 | dialog = SettingsDialog(self) 1239 | dialog.set_settings({ 1240 | "custom_prompt": self.settings["custom_prompt"], 1241 | "clean_response": self.settings["clean_response"], 1242 | "detail_level": self.settings["detail_level"] 1243 | }) 1244 | if dialog.exec(): 1245 | new_settings = dialog.get_settings() 1246 | self.settings["custom_prompt"] = new_settings["custom_prompt"] 1247 | self.settings["clean_response"] = new_settings["clean_response"] 1248 | self.settings["detail_level"] = new_settings["detail_level"] 1249 | 1250 | def add_folder(self): 1251 | """ 1252 | フォルダを選択し、その配下の画像をリストに追加 1253 | """ 1254 | folder_path = QFileDialog.getExistingDirectory(self, "フォルダを選択") 1255 | if folder_path: 1256 | self.add_images_from_folder(folder_path) 1257 | 1258 | def add_files(self): 1259 | """ 1260 | 複数ファイルを選択し、リストに追加 1261 | """ 1262 | file_paths, _ = QFileDialog.getOpenFileNames( 1263 | self, "画像ファイルを選択", "", 1264 | "画像ファイル (*.jpg *.jpeg *.png *.gif *.bmp *.webp *.heic)" 1265 | ) 1266 | if file_paths: 1267 | self.add_images(file_paths) 1268 | 1269 | def add_dropped_files(self, file_paths): 1270 | """ 1271 | ドラッグ&ドロップされたファイル/フォルダを処理 1272 | """ 1273 | image_paths = [] 1274 | folder_paths = [] 1275 | for path in file_paths: 1276 | if os.path.isdir(path): 1277 | folder_paths.append(path) 1278 | elif os.path.isfile(path) and self._is_supported_image(path): 1279 | image_paths.append(path) 1280 | if image_paths: 1281 | self.add_images(image_paths) 1282 | for folder in folder_paths: 1283 | self.add_images_from_folder(folder) 1284 | 1285 | def add_images_from_folder(self, folder_path): 1286 | """ 1287 | 指定フォルダ下のすべての画像をリストに追加 1288 | """ 1289 | supported_formats = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.avif'} 1290 | image_paths = [] 1291 | for root, _, files in os.walk(folder_path): 1292 | for file in files: 1293 | file_path = os.path.join(root, file) 1294 | if os.path.splitext(file_path)[1].lower() in supported_formats: 1295 | image_paths.append(file_path) 1296 | if image_paths: 1297 | self.add_images(image_paths) 1298 | 1299 | def add_images(self, image_paths): 1300 | """ 1301 | ファイルパスをリストに追加(既存チェック込み) 1302 | """ 1303 | existing = {str(self.image_list.item(i).image_path) for i in range(self.image_list.count())} 1304 | added = 0 1305 | for path in image_paths: 1306 | if path not in existing: 1307 | self.image_list.addItem(ImageListItem(path)) 1308 | added += 1 1309 | if added: 1310 | self.status_label.setText(f"{added}個の画像を追加") 1311 | 1312 | def _is_supported_image(self, file_path): 1313 | """ 1314 | 対応フォーマットか判定 1315 | """ 1316 | ext = os.path.splitext(file_path)[1].lower() 1317 | return ext in {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.avif'} 1318 | 1319 | def remove_selected(self): 1320 | """ 1321 | 選択された画像をリストから削除 1322 | """ 1323 | selected = self.image_list.selectedItems() 1324 | if not selected: 1325 | return 1326 | for item in selected: 1327 | row = self.image_list.row(item) 1328 | self.image_list.takeItem(row) 1329 | self.status_label.setText(f"{len(selected)}個の画像を削除") 1330 | if self.image_list.count(): 1331 | self.image_list.setCurrentRow(0) 1332 | else: 1333 | self.image_preview.set_image(None) 1334 | self.tag_display.set_tags("") 1335 | 1336 | def on_image_selected(self, current, previous): 1337 | """ 1338 | 選択中の画像をプレビューと分析結果欄に表示 1339 | """ 1340 | if not current: 1341 | self.image_preview.set_image(None) 1342 | self.tag_display.set_tags("") 1343 | return 1344 | image_path = current.image_path 1345 | self.image_preview.set_image(str(image_path)) 1346 | 1347 | text_path = image_path.with_suffix('.txt') 1348 | if text_path.exists(): 1349 | try: 1350 | with open(text_path, 'r', encoding='utf-8') as f: 1351 | content = f.read() 1352 | self.tag_display.set_tags(content) 1353 | except Exception as e: 1354 | logger.error(f"テキスト読み込みエラー: {str(e)}") 1355 | self.tag_display.set_tags("") 1356 | else: 1357 | self.tag_display.set_tags("") 1358 | 1359 | def run_analysis(self): 1360 | """ 1361 | 画像をまとめて分析し、結果をtxtファイルとして保存 1362 | """ 1363 | if self.image_list.count() == 0: 1364 | QMessageBox.warning(self, "警告", "分析する画像がありません。") 1365 | return 1366 | 1367 | selected = self.image_list.selectedItems() 1368 | if selected and len(selected) < self.image_list.count(): 1369 | reply = QMessageBox.question( 1370 | self, "確認", 1371 | f"選択された{len(selected)}個のみ分析しますか?\n「No」で全画像分析", 1372 | QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel 1373 | ) 1374 | if reply == QMessageBox.Cancel: 1375 | return 1376 | if reply == QMessageBox.Yes: 1377 | image_paths = [str(item.image_path) for item in selected] 1378 | else: 1379 | image_paths = [str(self.image_list.item(i).image_path) for i in range(self.image_list.count())] 1380 | else: 1381 | image_paths = [str(self.image_list.item(i).image_path) for i in range(self.image_list.count())] 1382 | 1383 | # メインウィンドウでAIサーバ未起動の場合は起動(機能はあるが停止は削除済み) 1384 | main_window = self.window() 1385 | if hasattr(main_window, "ai_process") and main_window.ai_process is None: 1386 | main_window.start_ai_server() 1387 | 1388 | # 設定を更新 1389 | self.settings["model"] = self.model_combo.currentText() 1390 | self.settings["api_url"] = self.url_edit.text() 1391 | self.settings["use_japanese"] = self.japanese_check.isChecked() 1392 | 1393 | # アナライザ生成 1394 | self.analyzer = ImageAnalyzer( 1395 | model=self.settings["model"], 1396 | use_japanese=self.settings["use_japanese"], 1397 | detail_level=self.settings["detail_level"], 1398 | custom_prompt=self.settings["custom_prompt"] if self.settings["custom_prompt"] else None, 1399 | clean_custom_response=self.settings["clean_response"], 1400 | api_url=self.settings["api_url"], 1401 | clean_patterns=self.settings["clean_patterns"] 1402 | ) 1403 | 1404 | # ワーカースレッド開始 1405 | self.worker = AnalysisWorker(self.analyzer, image_paths) 1406 | self.worker.progress_updated.connect(self.update_progress) 1407 | self.worker.analysis_complete.connect(self.on_analysis_complete) 1408 | self.worker.analysis_error.connect(self.on_analysis_error) 1409 | self.worker.image_analyzed.connect(self.on_image_analyzed) 1410 | 1411 | self.run_button.setEnabled(False) 1412 | self.stop_button.setEnabled(True) 1413 | self.progress_bar.setValue(0) 1414 | self.status_label.setText("分析開始...") 1415 | 1416 | self.worker.start() 1417 | 1418 | def stop_analysis(self): 1419 | """ 1420 | 分析処理を中断 1421 | """ 1422 | if self.worker and self.worker.isRunning(): 1423 | self.worker.stop() 1424 | self.status_label.setText("分析停止中...") 1425 | self.stop_button.setEnabled(False) 1426 | 1427 | def update_progress(self, value): 1428 | """ 1429 | 進捗バー更新 1430 | """ 1431 | self.progress_bar.setValue(int(value)) 1432 | 1433 | def on_analysis_complete(self, processed, errors): 1434 | """ 1435 | 分析完了後の処理 1436 | """ 1437 | self.run_button.setEnabled(True) 1438 | self.stop_button.setEnabled(False) 1439 | if errors: 1440 | self.status_label.setText(f"分析完了: {processed}件処理, {errors}件エラー") 1441 | else: 1442 | self.status_label.setText(f"分析完了: {processed}件処理") 1443 | 1444 | current = self.image_list.currentItem() 1445 | if current: 1446 | self.on_image_selected(current, None) 1447 | 1448 | def on_analysis_error(self, error_message): 1449 | """ 1450 | 分析中に発生したエラーを表示 1451 | """ 1452 | QMessageBox.critical(self, "エラー", f"分析エラー:\n{error_message}") 1453 | self.run_button.setEnabled(True) 1454 | self.stop_button.setEnabled(False) 1455 | self.status_label.setText("エラー発生") 1456 | 1457 | def on_image_analyzed(self, image_path, result): 1458 | """ 1459 | 各画像の分析結果をリアルタイムで反映 1460 | """ 1461 | current = self.image_list.currentItem() 1462 | if current and str(current.image_path) == image_path: 1463 | self.tag_display.set_tags(result) 1464 | 1465 | # ----------------- ファイル移動タブ ----------------- 1466 | class FileMoveTab(QWidget): 1467 | """ 1468 | 検索キーワードでtxtを絞り込み、該当する画像をコピーまたは移動するタブ 1469 | """ 1470 | def __init__(self, parent=None): 1471 | super().__init__(parent) 1472 | self.search_worker = None 1473 | self.operation_worker = None 1474 | self.matched_files = [] 1475 | self.init_ui() 1476 | 1477 | def init_ui(self): 1478 | main_layout = QVBoxLayout(self) 1479 | 1480 | # 検索条件 1481 | search_group = QGroupBox("検索条件") 1482 | search_layout = QVBoxLayout(search_group) 1483 | 1484 | folder_layout = QGridLayout() 1485 | folder_layout.addWidget(QLabel("ソースフォルダ:"), 0, 0) 1486 | self.source_edit = QLineEdit() 1487 | self.source_edit.setReadOnly(True) 1488 | folder_layout.addWidget(self.source_edit, 0, 1) 1489 | self.source_button = QPushButton("参照") 1490 | self.source_button.clicked.connect(self.browse_source) 1491 | folder_layout.addWidget(self.source_button, 0, 2) 1492 | 1493 | folder_layout.addWidget(QLabel("宛先フォルダ:"), 1, 0) 1494 | self.target_edit = QLineEdit() 1495 | self.target_edit.setReadOnly(True) 1496 | folder_layout.addWidget(self.target_edit, 1, 1) 1497 | self.target_button = QPushButton("参照") 1498 | self.target_button.clicked.connect(self.browse_target) 1499 | folder_layout.addWidget(self.target_button, 1, 2) 1500 | 1501 | search_layout.addLayout(folder_layout) 1502 | 1503 | search_layout.addWidget(QLabel("検索キーワード(カンマ区切り):")) 1504 | self.keyword_edit = QLineEdit() 1505 | search_layout.addWidget(self.keyword_edit) 1506 | 1507 | option_layout = QHBoxLayout() 1508 | self.search_mode_group = QGroupBox("検索モード") 1509 | mode_layout = QHBoxLayout(self.search_mode_group) 1510 | self.or_radio = QRadioButton("OR検索") 1511 | self.and_radio = QRadioButton("AND検索") 1512 | self.or_radio.setChecked(True) 1513 | mode_layout.addWidget(self.or_radio) 1514 | mode_layout.addWidget(self.and_radio) 1515 | self.regex_check = QCheckBox("正規表現を使用") 1516 | option_layout.addWidget(self.search_mode_group) 1517 | option_layout.addWidget(self.regex_check) 1518 | option_layout.addStretch() 1519 | search_layout.addLayout(option_layout) 1520 | 1521 | main_layout.addWidget(search_group) 1522 | 1523 | # 検索結果 1524 | result_group = QGroupBox("検索結果") 1525 | result_layout = QVBoxLayout(result_group) 1526 | 1527 | self.result_list = QListWidget() 1528 | self.result_list.setIconSize(QSize(100, 100)) 1529 | self.result_list.setViewMode(QListWidget.IconMode) 1530 | self.result_list.setResizeMode(QListWidget.Adjust) 1531 | self.result_list.setSpacing(10) 1532 | self.result_list.setSelectionMode(QListWidget.ExtendedSelection) 1533 | result_layout.addWidget(self.result_list) 1534 | 1535 | selection_layout = QHBoxLayout() 1536 | self.select_all_button = QPushButton("すべて選択") 1537 | self.select_all_button.clicked.connect(self.select_all) 1538 | self.select_none_button = QPushButton("選択解除") 1539 | self.select_none_button.clicked.connect(self.select_none) 1540 | self.selection_label = QLabel("0 / 0 個選択中") 1541 | selection_layout.addWidget(self.select_all_button) 1542 | selection_layout.addWidget(self.select_none_button) 1543 | selection_layout.addStretch() 1544 | selection_layout.addWidget(self.selection_label) 1545 | result_layout.addLayout(selection_layout) 1546 | 1547 | main_layout.addWidget(result_group, 1) 1548 | 1549 | # 下部操作ボタン 1550 | bottom_layout = QHBoxLayout() 1551 | 1552 | status_layout = QVBoxLayout() 1553 | self.status_label = QLabel("準備完了") 1554 | status_layout.addWidget(self.status_label) 1555 | 1556 | self.progress_bar = QProgressBar() 1557 | self.progress_bar.setRange(0, 100) 1558 | self.progress_bar.setValue(0) 1559 | status_layout.addWidget(self.progress_bar) 1560 | 1561 | bottom_layout.addLayout(status_layout, 1) 1562 | 1563 | self.search_button = QPushButton("検索") 1564 | self.search_button.setMinimumSize(100, 40) 1565 | self.search_button.setStyleSheet(""" 1566 | QPushButton { background-color: #2196F3; color: white; font-weight: bold; border-radius: 5px; } 1567 | QPushButton:hover { background-color: #1976D2; } 1568 | QPushButton:disabled { background-color: #cccccc; } 1569 | """) 1570 | self.search_button.clicked.connect(self.search_files) 1571 | 1572 | self.copy_button = QPushButton("コピー") 1573 | self.copy_button.setMinimumSize(100, 40) 1574 | self.copy_button.setStyleSheet(""" 1575 | QPushButton { background-color: #4CAF50; color: white; font-weight: bold; border-radius: 5px; } 1576 | QPushButton:hover { background-color: #45a049; } 1577 | QPushButton:disabled { background-color: #cccccc; } 1578 | """) 1579 | self.copy_button.clicked.connect(lambda: self.process_files("copy")) 1580 | self.copy_button.setEnabled(False) 1581 | 1582 | self.move_button = QPushButton("移動") 1583 | self.move_button.setMinimumSize(100, 40) 1584 | self.move_button.setStyleSheet(""" 1585 | QPushButton { background-color: #FF9800; color: white; font-weight: bold; border-radius: 5px; } 1586 | QPushButton:hover { background-color: #F57C00; } 1587 | QPushButton:disabled { background-color: #cccccc; } 1588 | """) 1589 | self.move_button.clicked.connect(lambda: self.process_files("move")) 1590 | self.move_button.setEnabled(False) 1591 | 1592 | bottom_layout.addWidget(self.search_button) 1593 | bottom_layout.addWidget(self.copy_button) 1594 | bottom_layout.addWidget(self.move_button) 1595 | 1596 | main_layout.addLayout(bottom_layout) 1597 | self.result_list.itemSelectionChanged.connect(self.update_selection_count) 1598 | 1599 | self.setLayout(main_layout) 1600 | 1601 | def browse_source(self): 1602 | """ 1603 | ソースフォルダの参照ダイアログを開き、選択されたパスを表示 1604 | """ 1605 | folder = QFileDialog.getExistingDirectory(self, "ソースフォルダを選択") 1606 | if folder: 1607 | self.source_edit.setText(folder) 1608 | 1609 | def browse_target(self): 1610 | """ 1611 | 宛先フォルダの参照ダイアログを開き、選択されたパスを表示 1612 | """ 1613 | folder = QFileDialog.getExistingDirectory(self, "宛先フォルダを選択") 1614 | if folder: 1615 | self.target_edit.setText(folder) 1616 | 1617 | def search_files(self): 1618 | """ 1619 | 指定キーワードでtxtファイルを検索 1620 | """ 1621 | source_dir = self.source_edit.text() 1622 | if not source_dir: 1623 | QMessageBox.warning(self, "警告", "ソースフォルダを選択してください。") 1624 | return 1625 | 1626 | keywords = self.keyword_edit.text().strip() 1627 | if not keywords: 1628 | QMessageBox.warning(self, "警告", "検索キーワードを入力してください。") 1629 | return 1630 | 1631 | search_terms = [term.strip() for term in keywords.split(',') if term.strip()] 1632 | search_mode = "AND" if self.and_radio.isChecked() else "OR" 1633 | use_regex = self.regex_check.isChecked() 1634 | 1635 | self.search_button.setEnabled(False) 1636 | self.copy_button.setEnabled(False) 1637 | self.move_button.setEnabled(False) 1638 | self.progress_bar.setValue(0) 1639 | self.status_label.setText("検索中...") 1640 | 1641 | self.result_list.clear() 1642 | self.matched_files = [] 1643 | self.update_selection_count() 1644 | 1645 | self.search_worker = FileSearchWorker(source_dir, search_terms, search_mode, use_regex) 1646 | self.search_worker.progress_updated.connect(self.update_progress) 1647 | self.search_worker.search_complete.connect(self.on_search_complete) 1648 | self.search_worker.search_error.connect(self.on_search_error) 1649 | self.search_worker.start() 1650 | 1651 | def on_search_complete(self, matched_files): 1652 | """ 1653 | 検索完了時に呼ばれるコールバック 1654 | """ 1655 | self.search_button.setEnabled(True) 1656 | self.matched_files = matched_files 1657 | if not matched_files: 1658 | self.status_label.setText("検索結果: 一致するファイルが見つかりませんでした") 1659 | return 1660 | for file_path in matched_files: 1661 | self.result_list.addItem(ImageListItem(file_path)) 1662 | self.status_label.setText(f"検索結果: {len(matched_files)}個のファイルが見つかりました") 1663 | self.copy_button.setEnabled(len(self.result_list.selectedItems()) > 0) 1664 | self.move_button.setEnabled(len(self.result_list.selectedItems()) > 0) 1665 | self.update_selection_count() 1666 | 1667 | def on_search_error(self, error_message): 1668 | """ 1669 | 検索中に発生したエラーを表示 1670 | """ 1671 | QMessageBox.critical(self, "エラー", f"検索エラー:\n{error_message}") 1672 | self.search_button.setEnabled(True) 1673 | self.status_label.setText("エラー発生") 1674 | 1675 | def select_all(self): 1676 | """ 1677 | リスト内のすべてのアイテムを選択 1678 | """ 1679 | self.result_list.selectAll() 1680 | 1681 | def select_none(self): 1682 | """ 1683 | リストの選択を解除 1684 | """ 1685 | self.result_list.clearSelection() 1686 | 1687 | def update_selection_count(self): 1688 | """ 1689 | リスト内アイテムの選択数を表示 1690 | """ 1691 | count = self.result_list.count() 1692 | selected = len(self.result_list.selectedItems()) 1693 | self.selection_label.setText(f"{selected} / {count} 個選択中") 1694 | self.copy_button.setEnabled(selected > 0) 1695 | self.move_button.setEnabled(selected > 0) 1696 | 1697 | def process_files(self, operation): 1698 | """ 1699 | 選択したファイルをコピーまたは移動し、.txtファイルも同時処理 1700 | """ 1701 | target_dir = self.target_edit.text() 1702 | if not target_dir: 1703 | QMessageBox.warning(self, "警告", "宛先フォルダを選択してください。") 1704 | return 1705 | 1706 | selected = self.result_list.selectedItems() 1707 | if not selected: 1708 | QMessageBox.warning(self, "警告", "ファイルを選択してください。") 1709 | return 1710 | 1711 | op_text = "コピー" if operation == "copy" else "移動" 1712 | reply = QMessageBox.question( 1713 | self, "確認", 1714 | f"選択された{len(selected)}個のファイルを{op_text}しますか?", 1715 | QMessageBox.Yes | QMessageBox.No 1716 | ) 1717 | if reply != QMessageBox.Yes: 1718 | return 1719 | 1720 | file_paths = [item.image_path for item in selected] 1721 | 1722 | self.search_button.setEnabled(False) 1723 | self.copy_button.setEnabled(False) 1724 | self.move_button.setEnabled(False) 1725 | self.progress_bar.setValue(0) 1726 | self.status_label.setText(f"{op_text}中...") 1727 | 1728 | self.operation_worker = FileOperationWorker(file_paths, target_dir, operation) 1729 | self.operation_worker.progress_updated.connect(self.update_progress) 1730 | self.operation_worker.operation_complete.connect( 1731 | lambda count: self.on_operation_complete(count, operation) 1732 | ) 1733 | self.operation_worker.operation_error.connect(self.on_operation_error) 1734 | self.operation_worker.start() 1735 | 1736 | def on_operation_complete(self, count, operation): 1737 | """ 1738 | ファイル操作完了時に呼ばれるコールバック 1739 | """ 1740 | self.search_button.setEnabled(True) 1741 | self.copy_button.setEnabled(True) 1742 | self.move_button.setEnabled(True) 1743 | op_text = "コピー" if operation == "copy" else "移動" 1744 | self.status_label.setText(f"{op_text}完了: {count}個のファイルを処理") 1745 | 1746 | # 移動の場合、成功したファイルをリストから削除 1747 | if operation == "move" and count > 0: 1748 | indices = sorted([self.result_list.row(item) for item in self.result_list.selectedItems()], reverse=True) 1749 | for i in indices: 1750 | self.result_list.takeItem(i) 1751 | self.update_selection_count() 1752 | 1753 | def on_operation_error(self, error_message): 1754 | """ 1755 | ファイル操作中に発生したエラーを表示 1756 | """ 1757 | QMessageBox.critical(self, "エラー", f"ファイル操作エラー:\n{error_message}") 1758 | self.search_button.setEnabled(True) 1759 | self.copy_button.setEnabled(True) 1760 | self.move_button.setEnabled(True) 1761 | self.status_label.setText("エラー発生") 1762 | 1763 | def update_progress(self, value): 1764 | """ 1765 | 進捗バー更新 1766 | """ 1767 | self.progress_bar.setValue(int(value)) 1768 | 1769 | # ----------------- 分析結果編集タブ ----------------- 1770 | class ResultEditTab(QWidget): 1771 | """ 1772 | 分析結果(txt)の一括編集機能を提供するタブ 1773 | """ 1774 | def __init__(self, parent=None): 1775 | super().__init__(parent) 1776 | self.current_folder = None 1777 | self.image_files = [] 1778 | self.init_ui() 1779 | 1780 | def init_ui(self): 1781 | layout = QVBoxLayout(self) 1782 | 1783 | # フォルダ選択 1784 | folder_layout = QHBoxLayout() 1785 | self.folder_edit = QLineEdit() 1786 | self.folder_edit.setReadOnly(True) 1787 | self.folder_button = QPushButton("フォルダ選択") 1788 | self.folder_button.clicked.connect(self.select_folder) 1789 | 1790 | folder_layout.addWidget(QLabel("編集対象フォルダ:")) 1791 | folder_layout.addWidget(self.folder_edit) 1792 | folder_layout.addWidget(self.folder_button) 1793 | layout.addLayout(folder_layout) 1794 | 1795 | # スプリッターで左右分割 1796 | main_splitter = QSplitter(Qt.Horizontal) 1797 | 1798 | left_widget = QWidget() 1799 | left_layout = QVBoxLayout(left_widget) 1800 | 1801 | self.file_list = QListWidget() 1802 | self.file_list.setIconSize(QSize(100, 100)) 1803 | self.file_list.setViewMode(QListWidget.IconMode) 1804 | self.file_list.setResizeMode(QListWidget.Adjust) 1805 | self.file_list.setSpacing(10) 1806 | self.file_list.setSelectionMode(QListWidget.SingleSelection) 1807 | self.file_list.currentItemChanged.connect(self.on_item_selected) 1808 | left_layout.addWidget(QLabel("画像ファイル一覧")) 1809 | left_layout.addWidget(self.file_list) 1810 | 1811 | main_splitter.addWidget(left_widget) 1812 | 1813 | right_widget = QWidget() 1814 | right_layout = QVBoxLayout(right_widget) 1815 | 1816 | self.preview = ImagePreviewWidget() 1817 | right_layout.addWidget(self.preview) 1818 | 1819 | self.text_edit = QTextEdit() 1820 | right_layout.addWidget(self.text_edit) 1821 | 1822 | save_layout = QHBoxLayout() 1823 | self.save_button = QPushButton("変更を保存") 1824 | self.save_button.clicked.connect(self.save_current_text) 1825 | save_layout.addStretch() 1826 | save_layout.addWidget(self.save_button) 1827 | right_layout.addLayout(save_layout) 1828 | 1829 | main_splitter.addWidget(right_widget) 1830 | main_splitter.setSizes([300, 500]) 1831 | layout.addWidget(main_splitter) 1832 | 1833 | op_group = QGroupBox("一括操作") 1834 | op_layout = QGridLayout(op_group) 1835 | 1836 | self.delete_edit = QLineEdit() 1837 | self.prefix_edit = QLineEdit() 1838 | self.suffix_edit = QLineEdit() 1839 | self.find_edit = QLineEdit() 1840 | self.replace_edit = QLineEdit() 1841 | 1842 | op_layout.addWidget(QLabel("削除する文字列:"), 0, 0) 1843 | op_layout.addWidget(self.delete_edit, 0, 1) 1844 | 1845 | op_layout.addWidget(QLabel("先頭に追加:"), 1, 0) 1846 | op_layout.addWidget(self.prefix_edit, 1, 1) 1847 | 1848 | op_layout.addWidget(QLabel("末尾に追加:"), 2, 0) 1849 | op_layout.addWidget(self.suffix_edit, 2, 1) 1850 | 1851 | op_layout.addWidget(QLabel("置換(検索):"), 3, 0) 1852 | op_layout.addWidget(self.find_edit, 3, 1) 1853 | op_layout.addWidget(QLabel("置換(新文字列):"), 4, 0) 1854 | op_layout.addWidget(self.replace_edit, 4, 1) 1855 | 1856 | self.run_button = QPushButton("一括実行") 1857 | self.run_button.clicked.connect(self.run_bulk_edit) 1858 | op_layout.addWidget(self.run_button, 5, 0, 1, 2) 1859 | 1860 | layout.addWidget(op_group) 1861 | 1862 | self.log_edit = QTextEdit() 1863 | self.log_edit.setReadOnly(True) 1864 | layout.addWidget(self.log_edit) 1865 | 1866 | self.setLayout(layout) 1867 | 1868 | def select_folder(self): 1869 | """ 1870 | 編集対象フォルダを選択し、画像と対応するtxtを一覧に読み込む 1871 | """ 1872 | folder = QFileDialog.getExistingDirectory(self, "編集対象フォルダを選択") 1873 | if folder: 1874 | self.current_folder = Path(folder) 1875 | self.folder_edit.setText(folder) 1876 | self.load_files(folder) 1877 | 1878 | def load_files(self, folder): 1879 | """ 1880 | 対応する画像ファイル(とtxt)の一覧を表示リストに追加 1881 | """ 1882 | self.file_list.clear() 1883 | self.image_files = [] 1884 | if not Path(folder).exists(): 1885 | return 1886 | supported = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp', '.heic', '.avif'} 1887 | for f in Path(folder).glob("*.*"): 1888 | if f.suffix.lower() in supported: 1889 | item = ImageListItem(str(f)) 1890 | self.file_list.addItem(item) 1891 | self.image_files.append(f) 1892 | 1893 | def on_item_selected(self, current, previous): 1894 | """ 1895 | リスト選択項目が変わったらプレビューとテキストを更新 1896 | """ 1897 | if not current: 1898 | self.preview.set_image(None) 1899 | self.text_edit.setText("") 1900 | return 1901 | image_path = current.image_path 1902 | self.preview.set_image(str(image_path)) 1903 | 1904 | txt_path = image_path.with_suffix('.txt') 1905 | if txt_path.exists(): 1906 | try: 1907 | with open(txt_path, "r", encoding="utf-8") as f: 1908 | self.text_edit.setText(f.read()) 1909 | except Exception as e: 1910 | logger.error(f"テキスト読み込みエラー: {str(e)}") 1911 | self.text_edit.setText("") 1912 | else: 1913 | self.text_edit.setText("") 1914 | 1915 | def save_current_text(self): 1916 | """ 1917 | 現在のテキストエディタ内容を.txtに保存 1918 | """ 1919 | current = self.file_list.currentItem() 1920 | if not current: 1921 | return 1922 | image_path = current.image_path 1923 | txt_path = image_path.with_suffix('.txt') 1924 | content = self.text_edit.toPlainText() 1925 | try: 1926 | with open(txt_path, "w", encoding="utf-8") as f: 1927 | f.write(content) 1928 | self.log_edit.append(f"{txt_path.name} に変更を保存しました。") 1929 | except Exception as e: 1930 | self.log_edit.append(f"保存エラー: {str(e)}") 1931 | 1932 | def run_bulk_edit(self): 1933 | """ 1934 | 指定フォルダ内の全.txtに対して削除/置換などを一括実行 1935 | """ 1936 | if not self.current_folder or not self.current_folder.exists(): 1937 | QMessageBox.warning(self, "警告", "編集対象フォルダを選択してください。") 1938 | return 1939 | 1940 | txt_files = [] 1941 | supported = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp', '.heic', '.avif'} 1942 | for f in self.current_folder.glob("*.*"): 1943 | if f.suffix.lower() in supported: 1944 | txt_path = f.with_suffix('.txt') 1945 | if txt_path.exists(): 1946 | txt_files.append(txt_path) 1947 | 1948 | if not txt_files: 1949 | QMessageBox.warning(self, "警告", "対応するtxtファイルが見つかりません。") 1950 | return 1951 | 1952 | self.log_edit.append(f"{len(txt_files)}個のファイルを処理開始...") 1953 | 1954 | delete_str = self.delete_edit.text() 1955 | prefix = self.prefix_edit.text() 1956 | suffix = self.suffix_edit.text() 1957 | find_str = self.find_edit.text() 1958 | replace_str = self.replace_edit.text() 1959 | 1960 | for txt_file in txt_files: 1961 | try: 1962 | with open(txt_file, "r", encoding="utf-8") as f: 1963 | content = f.read() 1964 | 1965 | # 削除処理 1966 | if delete_str: 1967 | content = content.replace(delete_str, "") 1968 | # 先頭追加 1969 | if prefix: 1970 | content = prefix + content 1971 | # 末尾追加 1972 | if suffix: 1973 | content = content + suffix 1974 | # 文字列置換 1975 | if find_str: 1976 | content = content.replace(find_str, replace_str) 1977 | 1978 | with open(txt_file, "w", encoding="utf-8") as f: 1979 | f.write(content) 1980 | self.log_edit.append(f"{txt_file.name}: 処理完了") 1981 | 1982 | except Exception as e: 1983 | self.log_edit.append(f"{txt_file.name}: エラー {str(e)}") 1984 | 1985 | self.log_edit.append("一括編集完了。") 1986 | 1987 | # ----------------- メインウィンドウ ----------------- 1988 | class MainWindow(QMainWindow): 1989 | """ 1990 | アプリケーションのメインウィンドウ 1991 | AIサーバーの起動(停止機能は削除済み)および各タブの管理を行う 1992 | """ 1993 | def __init__(self): 1994 | super().__init__() 1995 | self.setWindowTitle("画像タグ付け・ファイル移動ツール") 1996 | self.setMinimumSize(1000, 700) 1997 | 1998 | # ollamaプロセスを保持する変数(Noneの場合、未起動) 1999 | self.ai_process = None 2000 | 2001 | self.set_light_theme() 2002 | 2003 | main_widget = QWidget() 2004 | self.setCentralWidget(main_widget) 2005 | main_layout = QVBoxLayout(main_widget) 2006 | main_layout.setContentsMargins(10, 10, 10, 10) 2007 | 2008 | self.tabs = QTabWidget() 2009 | self.tagging_tab = ImageTaggingTab() 2010 | self.move_tab = FileMoveTab() 2011 | self.result_edit_tab = ResultEditTab() 2012 | self.chat_tab = ChatTab( 2013 | get_api_url_func=lambda: self.tagging_tab.url_edit.text(), 2014 | get_model_func=lambda: self.tagging_tab.model_combo.currentText() 2015 | ) 2016 | 2017 | self.tabs.addTab(self.tagging_tab, "画像タグ付け") 2018 | self.tabs.addTab(self.move_tab, "ファイル移動") 2019 | self.tabs.addTab(self.result_edit_tab, "分析結果編集") 2020 | self.tabs.addTab(self.chat_tab, "AIチャット") 2021 | main_layout.addWidget(self.tabs) 2022 | 2023 | self.statusBar = QStatusBar() 2024 | self.setStatusBar(self.statusBar) 2025 | self.statusBar.showMessage("準備完了") 2026 | 2027 | menubar = self.menuBar() 2028 | 2029 | # 「設定」メニュー 2030 | settings_menu = menubar.addMenu("設定") 2031 | deletion_action = QAction("削除パターン設定", self) 2032 | deletion_action.triggered.connect(self.open_deletion_pattern_dialog) 2033 | settings_menu.addAction(deletion_action) 2034 | 2035 | # 「AIサーバ」メニュー 2036 | ai_menu = menubar.addMenu("AIサーバ") 2037 | stop_ai_action = QAction("AIサーバ停止", self) 2038 | stop_ai_action.triggered.connect(self.stop_ai_server) 2039 | ai_menu.addAction(stop_ai_action) 2040 | ai_start_action = QAction("AIサーバ起動", self) 2041 | ai_start_action.triggered.connect(self.start_ai_server) 2042 | ai_menu.addAction(ai_start_action) 2043 | # 停止機能は削除済み 2044 | 2045 | def open_deletion_pattern_dialog(self): 2046 | """ 2047 | 削除パターン設定ダイアログを開く 2048 | """ 2049 | current_config = load_app_config().get("clean_patterns", { 2050 | "initial": default_initial_patterns, 2051 | "additional": default_additional_patterns 2052 | }) 2053 | dialog = DeletionPatternDialog(self, current_patterns=current_config) 2054 | if dialog.exec(): 2055 | new_patterns = dialog.get_patterns() 2056 | cfg = load_app_config() 2057 | cfg["clean_patterns"] = new_patterns 2058 | save_app_config(cfg) 2059 | self.tagging_tab.settings["clean_patterns"] = new_patterns 2060 | 2061 | 2062 | def stop_ai_server(self): 2063 | """ollama serve を終了""" 2064 | if self.ai_process and self.ai_process.poll() is None: 2065 | self.ai_process.terminate() 2066 | self.ai_process.wait(timeout=10) 2067 | self.ai_process = None 2068 | self.statusBar.showMessage("AIサーバを停止しました", 5000) 2069 | 2070 | def start_ai_server(self): 2071 | """ 2072 | ollama serve を起動する 2073 | Windowsの場合は新しいコンソールを開いて起動し、 2074 | macOS/Linuxではバックグラウンドで起動。 2075 | """ 2076 | if self.ai_process is not None: 2077 | QMessageBox.information(self, "情報", "既にAIサーバが起動しています。") 2078 | return 2079 | model = self.tagging_tab.model_combo.currentText() 2080 | try: 2081 | current_os = platform.system() 2082 | if current_os == "Windows": 2083 | # Windows環境で新しいコンソールを開いて起動 2084 | CREATE_NEW_CONSOLE = 0x00000010 2085 | CREATE_NEW_PROCESS_GROUP = 0x00000200 2086 | self.ai_process = subprocess.Popen( 2087 | ["ollama", "serve"], 2088 | creationflags=CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP 2089 | ) 2090 | else: 2091 | # macOS/Linuxではバックグラウンドで起動 2092 | self.ai_process = subprocess.Popen(["ollama", "serve"]) 2093 | 2094 | self.statusBar.showMessage(f"AIサーバ起動中: {model}") 2095 | except Exception as e: 2096 | QMessageBox.critical(self, "エラー", f"AIサーバ起動エラー: {str(e)}") 2097 | self.ai_process = None 2098 | 2099 | def set_light_theme(self): 2100 | """ 2101 | シンプルなライトテーマを適用(任意) 2102 | """ 2103 | palette = QPalette() 2104 | palette.setColor(QPalette.Window, QColor(240, 240, 240)) 2105 | palette.setColor(QPalette.WindowText, Qt.black) 2106 | palette.setColor(QPalette.Base, Qt.white) 2107 | palette.setColor(QPalette.AlternateBase, QColor(225, 225, 225)) 2108 | palette.setColor(QPalette.ToolTipBase, Qt.white) 2109 | palette.setColor(QPalette.ToolTipText, Qt.black) 2110 | palette.setColor(QPalette.Text, Qt.black) 2111 | palette.setColor(QPalette.Button, QColor(240, 240, 240)) 2112 | palette.setColor(QPalette.ButtonText, Qt.black) 2113 | palette.setColor(QPalette.Highlight, QColor(76, 163, 220)) 2114 | palette.setColor(QPalette.HighlightedText, Qt.white) 2115 | self.setPalette(palette) 2116 | 2117 | def closeEvent(self, event): 2118 | self.stop_ai_server() 2119 | event.accept() 2120 | 2121 | def main(): 2122 | """ 2123 | アプリケーションのエントリーポイント 2124 | """ 2125 | app = QApplication(sys.argv) 2126 | font = app.font() 2127 | font.setPointSize(10) 2128 | app.setFont(font) 2129 | window = MainWindow() 2130 | window.show() 2131 | sys.exit(app.exec()) 2132 | 2133 | if __name__ == "__main__": 2134 | main() 2135 | --------------------------------------------------------------------------------