GUIを追加する
This commit is contained in:
parent
d9ae2c7d1a
commit
ce369e2fa3
20
README.md
20
README.md
@ -1,3 +1,23 @@
|
|||||||
# image-analyzer-lab
|
# 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
BIN
docs/images/pyside6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/images/tkinter.png
Normal file
BIN
docs/images/tkinter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
51
docs/outline.md
Normal file
51
docs/outline.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# 画像解析ツール
|
||||||
|
|
||||||
|
|
||||||
|
## Library
|
||||||
|
|
||||||
|
* 可視化ツール
|
||||||
|
* Tkinter 標準ライブラリ,シンプルなウィンドウならすぐ作れる
|
||||||
|
* PySide6 LGPLライセンス / 大規模アプリやプロダクト向けにおすすめ
|
||||||
|
* PySimpleGUI× GUIウィンドウを作って画像を表示するため(無料では難しい)
|
||||||
|
* 画像分析ツール
|
||||||
|
* opencv-python ・・・ 画像/動画の読み込み(cv2.VideoCapture)とフレーム処理用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 可視化ツールの選定
|
||||||
|
|
||||||
|
#### Tkinterの特徴
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**長所**
|
||||||
|
|
||||||
|
* 標準ライブラリなので追加インストール不要(どの環境でも動く)
|
||||||
|
* 非常に軽量で起動も速い
|
||||||
|
* 基本的な GUI パーツ(ラベル、ボタン、テキスト入力、チェックボックスなど)は一通り揃っている
|
||||||
|
* 初心者でも学習コストが低い(ドキュメントやサンプルが豊富)
|
||||||
|
|
||||||
|
**短所**
|
||||||
|
|
||||||
|
* デザインが古い・野暮ったい(各OSのネイティブっぽさは出ない)
|
||||||
|
* カスタマイズ性が低い(スタイルやテーマの自由度が限られる)
|
||||||
|
* 近年のモダンなUI(フラットデザイン、レスポンシブ、アイコン付きボタンなど)は作りにくい
|
||||||
|
+ 大規模アプリや複雑な画面構成になると管理が難しい
|
||||||
|
|
||||||
|
#### PySide6の特徴
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**長所**
|
||||||
|
|
||||||
|
* Qtベースで本格的なGUIが作れる(業務用アプリにも対応可能)
|
||||||
|
* 見た目がモダンでOSのネイティブに近いデザイン
|
||||||
|
* Qt Designerで画面をドラッグ&ドロップ設計できる
|
||||||
|
* 豊富なウィジェットや機能(OpenGL, Web埋め込みなど)が利用可能
|
||||||
|
|
||||||
|
**短所**
|
||||||
|
|
||||||
|
* 学習コストが高め(シグナル/スロットなど独自の概念に慣れが必要)
|
||||||
|
* 実行ファイルサイズが大きくなりがち(PyInstallerで数十MB以上)
|
||||||
|
* インストールや依存関係が重い(環境によってバージョン注意)
|
||||||
|
* モバイル対応は限定的(基本はデスクトップ専用)
|
||||||
BIN
docs/sample/sample.jpg
Normal file
BIN
docs/sample/sample.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
61
examples/example_gui_pyside6.py
Normal file
61
examples/example_gui_pyside6.py
Normal 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())
|
||||||
41
examples/example_gui_tkinter.py
Normal file
41
examples/example_gui_tkinter.py
Normal 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
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
opencv-python
|
||||||
|
PySide6
|
||||||
229
src/video_player.py
Normal file
229
src/video_player.py
Normal 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())
|
||||||
Loading…
x
Reference in New Issue
Block a user