GUIを追加する

This commit is contained in:
ry.yamafuji 2025-09-11 23:02:16 +09:00
parent d9ae2c7d1a
commit ce369e2fa3
9 changed files with 405 additions and 1 deletions

View File

@ -1,3 +1,23 @@
# image-analyzer-lab
画像認識及び検知、セクションなどの視覚研究ラボ
画像認識及び検知、セクションなどの視覚研究ラボ
## Exec
```bat
python src\video_player.py
```
## Setup
```sh
python -m venv venv
# windows
venv\Scripts\activate
# package
pip install -r requirements.txt
```
```sh
python -m pip install --upgrade --extra-index-url https://PySimpleGUI.net/install PySimpleGUI
```

BIN
docs/images/pyside6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
docs/images/tkinter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

51
docs/outline.md Normal file
View File

@ -0,0 +1,51 @@
# 画像解析ツール
## Library
* 可視化ツール
* Tkinter 標準ライブラリ,シンプルなウィンドウならすぐ作れる
* PySide6 LGPLライセンス / 大規模アプリやプロダクト向けにおすすめ
* PySimpleGUI× GUIウィンドウを作って画像を表示するため(無料では難しい)
* 画像分析ツール
* opencv-python ・・・ 画像/動画の読み込み(cv2.VideoCapture)とフレーム処理用
---
### 可視化ツールの選定
#### Tkinterの特徴
![Tkinter](images/tkinter.png)
**長所**
* 標準ライブラリなので追加インストール不要(どの環境でも動く)
* 非常に軽量で起動も速い
* 基本的な GUI パーツ(ラベル、ボタン、テキスト入力、チェックボックスなど)は一通り揃っている
* 初心者でも学習コストが低い(ドキュメントやサンプルが豊富)
**短所**
* デザインが古い・野暮ったい(各OSのネイティブっぽさは出ない)
* カスタマイズ性が低い(スタイルやテーマの自由度が限られる)
* 近年のモダンなUI(フラットデザイン、レスポンシブ、アイコン付きボタンなど)は作りにくい
+ 大規模アプリや複雑な画面構成になると管理が難しい
#### PySide6の特徴
![PySide6](images/pyside6.png)
**長所**
* Qtベースで本格的なGUIが作れる(業務用アプリにも対応可能)
* 見た目がモダンでOSのネイティブに近いデザイン
* Qt Designerで画面をドラッグ&ドロップ設計できる
* 豊富なウィジェットや機能(OpenGL, Web埋め込みなど)が利用可能
**短所**
* 学習コストが高め(シグナル/スロットなど独自の概念に慣れが必要)
* 実行ファイルサイズが大きくなりがち(PyInstallerで数十MB以上)
* インストールや依存関係が重い(環境によってバージョン注意)
* モバイル対応は限定的(基本はデスクトップ専用)

BIN
docs/sample/sample.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -0,0 +1,61 @@
import sys
from PySide6.QtWidgets import (
QApplication, QWidget, QLabel, QLineEdit, QCheckBox, QListWidget,
QPushButton, QVBoxLayout, QHBoxLayout, QMessageBox
)
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("PySide6 GUI サンプル")
self.resize(500, 300)
# --- 左側ウィジェット ---
label = QLabel("名前を入力してください:")
self.entry = QLineEdit()
self.chk = QCheckBox("同意します")
left_layout = QVBoxLayout()
left_layout.addWidget(label)
left_layout.addWidget(self.entry)
left_layout.addWidget(self.chk)
left_layout.addStretch()
# --- 右側ウィジェット ---
self.listbox = QListWidget()
for item in ["りんご", "バナナ", "みかん", "ぶどう"]:
self.listbox.addItem(item)
btn_exec = QPushButton("実行")
btn_exec.clicked.connect(self.on_exec)
btn_quit = QPushButton("終了")
btn_quit.clicked.connect(self.close)
right_layout = QVBoxLayout()
right_layout.addWidget(self.listbox)
right_layout.addWidget(btn_exec)
right_layout.addWidget(btn_quit)
right_layout.addStretch()
# --- メインレイアウト(横並び) ---
main_layout = QHBoxLayout()
main_layout.addLayout(left_layout, 1)
main_layout.addLayout(right_layout, 1)
self.setLayout(main_layout)
def on_exec(self):
name = self.entry.text()
checked = "はい" if self.chk.isChecked() else "いいえ"
item = self.listbox.currentItem().text() if self.listbox.currentItem() else "未選択"
msg = f"こんにちは {name} さん\nチェック: {checked}\n選択: {item}"
QMessageBox.information(self, "挨拶", msg)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

View File

@ -0,0 +1,41 @@
import tkinter as tk
from tkinter import messagebox
def on_click():
name = entry.get()
checked = var_chk.get()
msg = f"こんにちは {name} さん!\nチェック状態: {checked}"
messagebox.showinfo("挨拶", msg)
root = tk.Tk()
root.title("Tkinter GUI サンプル")
root.geometry("400x300") # 幅×高さ
# ラベル
label = tk.Label(root, text="名前を入力してください:", font=("Arial", 12))
label.pack(pady=5)
# 入力欄
entry = tk.Entry(root, width=30)
entry.pack(pady=5)
# チェックボックス
var_chk = tk.BooleanVar()
chk = tk.Checkbutton(root, text="同意します", variable=var_chk)
chk.pack(pady=5)
# リストボックス
listbox = tk.Listbox(root, height=4)
for item in ["りんご", "バナナ", "みかん", "ぶどう"]:
listbox.insert(tk.END, item)
listbox.pack(pady=5)
# ボタン
btn = tk.Button(root, text="実行", command=on_click)
btn.pack(pady=10)
# 終了ボタン
btn_quit = tk.Button(root, text="終了", command=root.quit)
btn_quit.pack(pady=5)
root.mainloop()

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
opencv-python
PySide6

229
src/video_player.py Normal file
View File

@ -0,0 +1,229 @@
import sys, os, cv2, numpy as np
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QImage, QPixmap, QDragEnterEvent, QDropEvent
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QLabel, QPushButton, QFileDialog,
QVBoxLayout, QHBoxLayout, QSplitter, QTabWidget, QFormLayout, QSpinBox, QCheckBox
)
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".gif"}
VIDEO_EXT = {".mp4", ".mov", ".avi", ".mkv", ".m4v"}
def bgr_to_qpixmap(bgr):
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch*w, QImage.Format.Format_RGB888)
return QPixmap.fromImage(qimg)
class MediaViewer(QWidget):
"""画像と動画を共通で扱う左側ビューワ"""
def __init__(self, parent=None):
super().__init__(parent)
self.label = QLabel("Drop files here\n(画像/動画)")
self.label.setAlignment(Qt.AlignCenter)
self.label.setStyleSheet("background:#222;color:#aaa;border:1px solid #444;")
self.label.setMinimumSize(640, 360)
self.btn_bar = QHBoxLayout()
self.btn_open = QPushButton("Open")
self.btn_play = QPushButton("Play")
self.btn_pause = QPushButton("Pause")
self.btn_stop = QPushButton("Stop")
for b in (self.btn_open, self.btn_play, self.btn_pause, self.btn_stop):
self.btn_bar.addWidget(b)
self.btn_bar.addStretch()
lay = QVBoxLayout(self)
lay.addWidget(self.label, 1)
lay.addLayout(self.btn_bar)
# 動画用
self.timer = QTimer(self)
self.timer.timeout.connect(self._next_frame)
self.cap = None
self.current_path = None
self._is_video = False
# イベント
self.btn_open.clicked.connect(self.open_file)
self.btn_play.clicked.connect(self.play)
self.btn_pause.clicked.connect(self.pause)
self.btn_stop.clicked.connect(self.stop)
# D&D 有効化
self.setAcceptDrops(True)
# ---- Public API ----
def load(self, path: str):
self.stop()
self.current_path = path
suffix = Path(path).suffix.lower()
if suffix in IMAGE_EXT:
self._is_video = False
data = np.fromfile(path, dtype=np.uint8)
img = cv2.imdecode(data, cv2.IMREAD_COLOR)
if img is None:
self.label.setText("画像を開けませんでした")
return
self.label.setPixmap(bgr_to_qpixmap(img).scaled(
self.label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
elif suffix in VIDEO_EXT:
self._is_video = True
self.cap = cv2.VideoCapture(path)
if not self.cap.isOpened():
self._is_video = False
self.label.setText("動画を開けませんでした")
return
self.play()
else:
self.label.setText("未対応の形式です")
def open_file(self):
path, _ = QFileDialog.getOpenFileName(self, "Open media", "",
"Media (*.jpg *.jpeg *.png *.bmp *.gif *.mp4 *.mov *.avi *.mkv *.m4v)")
if path:
self.load(path)
def play(self):
if not self._is_video:
return
if self.cap is None:
return
# 30fps程度で更新必要に応じて調整
self.timer.start(33)
def pause(self):
self.timer.stop()
def stop(self):
self.timer.stop()
if self.cap is not None:
self.cap.release()
self.cap = None
# ---- Internal ----
def _next_frame(self):
if self.cap is None:
return
ok, frame = self.cap.read()
if not ok:
self.pause()
return
self.label.setPixmap(bgr_to_qpixmap(frame).scaled(
self.label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
# ---- D&D ----
def dragEnterEvent(self, e: QDragEnterEvent):
if e.mimeData().hasUrls():
e.acceptProposedAction()
def dropEvent(self, e: QDropEvent):
urls = e.mimeData().urls()
if not urls:
return
path = urls[0].toLocalFile()
if os.path.isfile(path):
self.load(path)
# リサイズ時もアスペクト保持で表示更新
def resizeEvent(self, ev):
super().resizeEvent(ev)
if self.current_path and not self._is_video and self.label.pixmap():
# 静止画のみスケールし直し
self.open_image_again()
def open_image_again(self):
if not self.current_path or self._is_video:
return
data = np.fromfile(self.current_path, dtype=np.uint8)
img = cv2.imdecode(data, cv2.IMREAD_COLOR)
if img is None:
return
self.label.setPixmap(bgr_to_qpixmap(img).scaled(
self.label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
class RightPanel(QWidget):
"""右側:プレビュー/設定のタブ"""
def __init__(self, parent=None):
super().__init__(parent)
tabs = QTabWidget()
# Analyser タブ(解析)
self.analyser = QWidget()
a_lay = QVBoxLayout(self.analyser)
# ボタンを追加
a_lay.addWidget(QPushButton("Run Analysis"))
a_lay.addStretch()
# Preview タブ(分析結果の簡易表示など)
self.preview = QWidget()
pv_lay = QVBoxLayout(self.preview)
pv_lay.addWidget(QLabel("分析プレビュー(ここに結果サムネ/メトリクス等)"))
pv_lay.addStretch()
# Settings タブ(しきい値や切替など)
self.settings = QWidget()
st_form = QFormLayout(self.settings)
self.spin_conf = QSpinBox(); self.spin_conf.setRange(10, 90); self.spin_conf.setValue(35)
self.chk_boxes = QCheckBox("検知枠を表示"); self.chk_boxes.setChecked(True)
st_form.addRow("信頼度(%)", self.spin_conf)
st_form.addRow(self.chk_boxes)
tabs.addTab(self.analyser, "Analyser")
tabs.addTab(self.settings, "Settings")
tabs.addTab(self.preview, "Preview")
lay = QVBoxLayout(self)
lay.addWidget(tabs)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("CV Studio (Prototype)")
self.resize(1200, 700)
self.viewer = MediaViewer()
self.right = RightPanel()
splitter = QSplitter()
splitter.addWidget(self.viewer)
splitter.addWidget(self.right)
# 半々に分割
splitter.setSizes([self.width()//2, self.width()//2])
# 2.1
# splitter.setStretchFactor(0, 3)
# splitter.setStretchFactor(1, 2)
self.setCentralWidget(splitter)
self._build_menu()
self.setAcceptDrops(True)
def _build_menu(self):
open_act = QAction("Open...", self)
open_act.triggered.connect(self.viewer.open_file)
self.menuBar().addMenu("File").addAction(open_act)
# ルートウィンドウにもD&Dフォルダから直接落とせるように
def dragEnterEvent(self, e: QDragEnterEvent):
if e.mimeData().hasUrls():
e.acceptProposedAction()
def dropEvent(self, e: QDropEvent):
urls = e.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
if os.path.isfile(path):
self.viewer.load(path)
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())