diff --git a/README.md b/README.md index 8e3210d..2639fc7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ # image-analyzer-lab -画像認識及び検知、セクションなどの視覚研究ラボ \ No newline at end of file +画像認識及び検知、セクションなどの視覚研究ラボ + +## 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 +``` diff --git a/docs/images/pyside6.png b/docs/images/pyside6.png new file mode 100644 index 0000000..74f043a Binary files /dev/null and b/docs/images/pyside6.png differ diff --git a/docs/images/tkinter.png b/docs/images/tkinter.png new file mode 100644 index 0000000..2f14d6c Binary files /dev/null and b/docs/images/tkinter.png differ diff --git a/docs/outline.md b/docs/outline.md new file mode 100644 index 0000000..9eb0f84 --- /dev/null +++ b/docs/outline.md @@ -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以上) +* インストールや依存関係が重い(環境によってバージョン注意) +* モバイル対応は限定的(基本はデスクトップ専用) diff --git a/docs/sample/sample.jpg b/docs/sample/sample.jpg new file mode 100644 index 0000000..a5ab946 Binary files /dev/null and b/docs/sample/sample.jpg differ diff --git a/examples/example_gui_pyside6.py b/examples/example_gui_pyside6.py new file mode 100644 index 0000000..ce97119 --- /dev/null +++ b/examples/example_gui_pyside6.py @@ -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()) diff --git a/examples/example_gui_tkinter.py b/examples/example_gui_tkinter.py new file mode 100644 index 0000000..1acfd7b --- /dev/null +++ b/examples/example_gui_tkinter.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e741782 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +opencv-python +PySide6 \ No newline at end of file diff --git a/src/video_player.py b/src/video_player.py new file mode 100644 index 0000000..7d1c40b --- /dev/null +++ b/src/video_player.py @@ -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())