Spaces:
Running
Running
Merge branch 'develop'
Browse files- .gitignore +5 -0
- Dockerfile +11 -1
- README.md +55 -54
- app.py +210 -355
- pyproject.toml +163 -0
- requirements.txt +4 -3
- scripts/show_structure.py +58 -0
- src/frame_bridge/__init__.py +20 -0
- src/frame_bridge/batch_processor.py +311 -0
- src/frame_bridge/config.py +46 -0
- src/frame_bridge/video_processor.py +399 -0
- tests/batch_test.py +96 -0
- tests/test_sample.py +72 -0
.gitignore
CHANGED
@@ -206,3 +206,8 @@ marimo/_static/
|
|
206 |
marimo/_lsp/
|
207 |
__marimo__/
|
208 |
.SourceSageAssets/
|
|
|
|
|
|
|
|
|
|
|
|
206 |
marimo/_lsp/
|
207 |
__marimo__/
|
208 |
.SourceSageAssets/
|
209 |
+
assets/*.mp4
|
210 |
+
uv.lock
|
211 |
+
assets/example/REI/input/*.mp4
|
212 |
+
assets/example/REI/output/*.mp4
|
213 |
+
assets/example/REI/output/batch_report.txt
|
Dockerfile
CHANGED
@@ -4,8 +4,18 @@ FROM python:3.11-slim
|
|
4 |
# 作業ディレクトリを設定
|
5 |
WORKDIR /app
|
6 |
|
7 |
-
#
|
8 |
RUN apt-get update && apt-get install -y \
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
&& rm -rf /var/lib/apt/lists/*
|
10 |
|
11 |
# 依存関係ファイルをコピー
|
|
|
4 |
# 作業ディレクトリを設定
|
5 |
WORKDIR /app
|
6 |
|
7 |
+
# システムパッケージの更新とOpenCV用ライブラリをインストール
|
8 |
RUN apt-get update && apt-get install -y \
|
9 |
+
libglib2.0-0 \
|
10 |
+
libsm6 \
|
11 |
+
libxext6 \
|
12 |
+
libxrender-dev \
|
13 |
+
libgomp1 \
|
14 |
+
libglib2.0-0 \
|
15 |
+
libgtk-3-0 \
|
16 |
+
libavcodec-dev \
|
17 |
+
libavformat-dev \
|
18 |
+
libswscale-dev \
|
19 |
&& rm -rf /var/lib/apt/lists/*
|
20 |
|
21 |
# 依存関係ファイルをコピー
|
README.md
CHANGED
@@ -2,9 +2,9 @@
|
|
2 |
license: mit
|
3 |
title: frame bridge
|
4 |
sdk: gradio
|
5 |
-
emoji:
|
6 |
-
colorFrom:
|
7 |
-
colorTo:
|
8 |
thumbnail: >-
|
9 |
https://cdn-uploads.huggingface.co/production/uploads/64e0ef4a4c78e1eba5178d7a/BZfofcX1vEF7kwWQ0i-uB.png
|
10 |
sdk_version: 5.42.0
|
@@ -14,14 +14,15 @@ sdk_version: 5.42.0
|
|
14 |
|
15 |

|
16 |
|
17 |
-
#
|
18 |
|
19 |
-
*
|
20 |
|
21 |
[](https://python.org)
|
|
|
22 |
[](https://gradio.app)
|
23 |
[](LICENSE)
|
24 |
-
[](https://huggingface.co/spaces/MakiAi/
|
25 |
|
26 |
</div>
|
27 |
|
@@ -29,29 +30,29 @@ sdk_version: 5.42.0
|
|
29 |
|
30 |
## 🌟 概要
|
31 |
|
32 |
-
**
|
33 |
|
34 |
### ✨ **主要機能**
|
35 |
|
36 |
-
-
|
37 |
-
-
|
38 |
-
-
|
39 |
-
-
|
40 |
-
-
|
41 |
|
42 |
---
|
43 |
|
44 |
## 🚀 使い方
|
45 |
|
46 |
-
###
|
47 |
-
**[🚀 デモサイトはこちら](https://huggingface.co/spaces/MakiAi/
|
48 |
|
49 |
### 💻 **ローカルで実行**
|
50 |
|
51 |
```bash
|
52 |
# リポジトリをクローン
|
53 |
-
git clone https://github.com/
|
54 |
-
cd
|
55 |
|
56 |
# 依存関係をインストール
|
57 |
pip install -r requirements.txt
|
@@ -73,40 +74,34 @@ docker-compose up -d
|
|
73 |
|
74 |
## 📋 操作方法
|
75 |
|
76 |
-
###
|
77 |
-
1.
|
78 |
-
2.
|
79 |
-
3.
|
|
|
80 |
|
81 |
-
###
|
82 |
-
1. 複数のURLを1行に1つずつ入力
|
83 |
-
2. 「🚀 一括変換する」ボタンをクリック
|
84 |
-
3. 処理結果を確認し、必要な形式でダウンロード
|
85 |
-
|
86 |
-
### 📊 **処理結果の表示例**
|
87 |
```
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
📊 文字数: 15,432 文字
|
98 |
-
💾 ファイル名: Python.md
|
99 |
```
|
100 |
|
101 |
---
|
102 |
|
103 |
-
##
|
104 |
|
105 |
-
|
|
106 |
|------|------|------|
|
107 |
-
|
|
108 |
-
|
|
109 |
-
|
|
110 |
|
111 |
---
|
112 |
|
@@ -114,25 +109,28 @@ docker-compose up -d
|
|
114 |
|
115 |
### **使用技術**
|
116 |
- **Python 3.8+** - メイン言語
|
|
|
|
|
117 |
- **Gradio** - Webインターフェース
|
118 |
-
- **
|
|
|
119 |
- **html2text** - Markdown変換
|
120 |
- **Requests** - HTTP通信
|
121 |
|
122 |
### **処理フロー**
|
123 |
-
1.
|
124 |
-
2.
|
125 |
-
3.
|
126 |
-
4.
|
127 |
-
5.
|
128 |
-
6.
|
129 |
|
130 |
---
|
131 |
|
132 |
## 📁 プロジェクト構成
|
133 |
|
134 |
```
|
135 |
-
|
136 |
├── app.py # メインアプリケーション
|
137 |
├── theme.py # UIテーマ設定
|
138 |
├── requirements.txt # Python依存関係
|
@@ -148,8 +146,11 @@ wikipedia-to-markdown/
|
|
148 |
### **テーマ変更**
|
149 |
`theme.py`を編集してUIの色やスタイルを変更できます。
|
150 |
|
151 |
-
###
|
152 |
-
`app.py`の`
|
|
|
|
|
|
|
153 |
|
154 |
---
|
155 |
|
@@ -161,7 +162,7 @@ wikipedia-to-markdown/
|
|
161 |
|
162 |
## 🤝 コントリビューション
|
163 |
|
164 |
-
バグ報告や機能提案は[GitHub Issues](https://github.com/
|
165 |
|
166 |
---
|
167 |
|
@@ -169,6 +170,6 @@ wikipedia-to-markdown/
|
|
169 |
|
170 |
**🌟 このプロジェクトが役に立ったらスターをお願いします!**
|
171 |
|
172 |
-
*© 2025
|
173 |
|
174 |
</div>
|
|
|
2 |
license: mit
|
3 |
title: frame bridge
|
4 |
sdk: gradio
|
5 |
+
emoji: 🎬
|
6 |
+
colorFrom: purple
|
7 |
+
colorTo: blue
|
8 |
thumbnail: >-
|
9 |
https://cdn-uploads.huggingface.co/production/uploads/64e0ef4a4c78e1eba5178d7a/BZfofcX1vEF7kwWQ0i-uB.png
|
10 |
sdk_version: 5.42.0
|
|
|
14 |
|
15 |

|
16 |
|
17 |
+
# 🎬 Frame Bridge
|
18 |
|
19 |
+
*2つの動画を最適なフレームで自動結合するAIアプリケーション*
|
20 |
|
21 |
[](https://python.org)
|
22 |
+
[](https://opencv.org)
|
23 |
[](https://gradio.app)
|
24 |
[](LICENSE)
|
25 |
+
[](https://huggingface.co/spaces/MakiAi/frame-bridge)
|
26 |
|
27 |
</div>
|
28 |
|
|
|
30 |
|
31 |
## 🌟 概要
|
32 |
|
33 |
+
**Frame Bridge** は、2つの動画を視覚的に最適なフレームで自動結合するAIアプリケーションです。SSIM(構造的類似性指標)を使用して、動画1の終了部分と動画2の開始部分から最も類似したフレームを検出し、スムーズな動画結合を実現します。
|
34 |
|
35 |
### ✨ **主要機能**
|
36 |
|
37 |
+
- 🤖 **AI自動分析** - SSIM技術による高精度フレーム類似度計算
|
38 |
+
- 🎯 **最適接続点検出** - 動画間の最も自然な結合点を自動検出
|
39 |
+
- 📊 **リアルタイム分析** - 動画情報の即座表示と詳細分析
|
40 |
+
- 🎬 **スムーズ結合** - 視覚的に自然な動画結合を実現
|
41 |
+
- 🖼️ **接続フレーム表示** - 結合に使用されるフレームの可視化
|
42 |
|
43 |
---
|
44 |
|
45 |
## 🚀 使い方
|
46 |
|
47 |
+
### 🌐 **オンラインで試す(推奨)**
|
48 |
+
**[🚀 デモサイトはこちら](https://huggingface.co/spaces/MakiAi/frame-bridge)**
|
49 |
|
50 |
### 💻 **ローカルで実行**
|
51 |
|
52 |
```bash
|
53 |
# リポジトリをクローン
|
54 |
+
git clone https://github.com/Sunwood-ai-labsII/frame-bridge.git
|
55 |
+
cd frame-bridge
|
56 |
|
57 |
# 依存関係をインストール
|
58 |
pip install -r requirements.txt
|
|
|
74 |
|
75 |
## 📋 操作方法
|
76 |
|
77 |
+
### 🎬 **動画結合の手順**
|
78 |
+
1. **動画1(前半)** をアップロード
|
79 |
+
2. **動画2(後半)** をアップロード
|
80 |
+
3. 「🌉 フレームブリッジ実行」ボタンをクリック
|
81 |
+
4. AI分析結果と結合された動画をダウンロード
|
82 |
|
83 |
+
### 📊 **分析結果の表示例**
|
|
|
|
|
|
|
|
|
|
|
84 |
```
|
85 |
+
🎬 動画結合完了!
|
86 |
+
|
87 |
+
📊 分析結果:
|
88 |
+
• フレーム類似度: 0.847
|
89 |
+
• 接続品質: 優秀
|
90 |
+
• 結合情報:
|
91 |
+
• 動画1の最適な終了フレームを検出
|
92 |
+
• 動画2の最適な開始フレームを検出
|
93 |
+
• スムーズな接続を実現
|
|
|
|
|
94 |
```
|
95 |
|
96 |
---
|
97 |
|
98 |
+
## 🎯 技術的特徴
|
99 |
|
100 |
+
| 技術 | 説明 | 効果 |
|
101 |
|------|------|------|
|
102 |
+
| **SSIM分析** | 構造的類似性指標による高精度フレーム比較 | 視覚的に自然な結合点検出 |
|
103 |
+
| **自動最適化** | AI による最適接続フレーム自動検出 | 手動編集不要 |
|
104 |
+
| **リアルタイム分析** | 動画アップロード時の即座情報表示 | 効率的なワークフロー |
|
105 |
|
106 |
---
|
107 |
|
|
|
109 |
|
110 |
### **使用技術**
|
111 |
- **Python 3.8+** - メイン言語
|
112 |
+
- **OpenCV** - 動画処理・フレーム抽出
|
113 |
+
- **scikit-image** - SSIM計算
|
114 |
- **Gradio** - Webインターフェース
|
115 |
+
- **NumPy** - 数値計算
|
116 |
+
- **Pillow** - 画像処理
|
117 |
- **html2text** - Markdown変換
|
118 |
- **Requests** - HTTP通信
|
119 |
|
120 |
### **処理フロー**
|
121 |
+
1. **動画アップロード** - 2つの動画ファイルをアップロード
|
122 |
+
2. **フレーム抽出** - 各動画から代表フレームを抽出
|
123 |
+
3. **類似度計算** - SSIM技術による高精度フレーム比較
|
124 |
+
4. **最適点検出** - 最も類似度の高い接続フレームを特定
|
125 |
+
5. **動画結合** - 検出された最適点で動画を結合
|
126 |
+
6. **結果出力** - 結合動画と分析結果を提供
|
127 |
|
128 |
---
|
129 |
|
130 |
## 📁 プロジェクト構成
|
131 |
|
132 |
```
|
133 |
+
frame-bridge/
|
134 |
├── app.py # メインアプリケーション
|
135 |
├── theme.py # UIテーマ設定
|
136 |
├── requirements.txt # Python依存関係
|
|
|
146 |
### **テーマ変更**
|
147 |
`theme.py`を編集してUIの色やスタイルを変更できます。
|
148 |
|
149 |
+
### **アルゴリズム調整**
|
150 |
+
`app.py`の`find_best_connection_frames()`関数を編集して、フレーム分析ロジックをカスタマイズできます。
|
151 |
+
|
152 |
+
### **類似度閾値調整**
|
153 |
+
SSIM計算の精度や比較フレーム数を調整して、結合品質を最適化できます。
|
154 |
|
155 |
---
|
156 |
|
|
|
162 |
|
163 |
## 🤝 コントリビューション
|
164 |
|
165 |
+
バグ報告や機能提案は[GitHub Issues](https://github.com/Sunwood-ai-labsII/frame-bridge/issues)でお願いします。
|
166 |
|
167 |
---
|
168 |
|
|
|
170 |
|
171 |
**🌟 このプロジェクトが役に立ったらスターをお願いします!**
|
172 |
|
173 |
+
*© 2025 Frame Bridge - AI Video Merger*
|
174 |
|
175 |
</div>
|
app.py
CHANGED
@@ -1,418 +1,273 @@
|
|
1 |
-
import requests
|
2 |
-
from bs4 import BeautifulSoup
|
3 |
-
import html2text
|
4 |
-
import re
|
5 |
import gradio as gr
|
6 |
from theme import create_zen_theme
|
7 |
-
import
|
8 |
import os
|
9 |
-
import zipfile
|
10 |
-
from urllib.parse import urlparse, unquote
|
11 |
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
タイトルを付けてMarkdownに変換します。
|
16 |
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
Returns:
|
28 |
-
str: 整形・変換された最終的なMarkdownコンテンツ。失敗した場合は空の文字列。
|
29 |
-
"""
|
30 |
-
try:
|
31 |
-
# 1. HTMLの取得と解析
|
32 |
-
headers = {
|
33 |
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
34 |
-
}
|
35 |
-
response = requests.get(url, headers=headers)
|
36 |
-
response.raise_for_status() # HTTPエラーがあれば例外を発生させる
|
37 |
-
response.encoding = response.apparent_encoding # 文字コードを自動検出
|
38 |
-
soup = BeautifulSoup(response.text, 'html.parser')
|
39 |
-
|
40 |
-
# --- ページのタイトルを取得 ---
|
41 |
-
title_tag = soup.find('h1', id='firstHeading')
|
42 |
-
page_title = title_tag.get_text(strip=True) if title_tag else "Wikipedia ページ"
|
43 |
-
|
44 |
-
# 2. 主要コンテンツエリアの特定
|
45 |
-
content_div = soup.find('div', class_='mw-parser-output')
|
46 |
-
if not content_div:
|
47 |
-
return "エラー: コンテンツエリアが見つかりませんでした。"
|
48 |
-
|
49 |
-
# 3. HTMLの事前整形(登場人物などの見出し化)
|
50 |
-
for dt_tag in content_div.find_all('dt'):
|
51 |
-
h4_tag = soup.new_tag('h4')
|
52 |
-
h4_tag.extend(dt_tag.contents)
|
53 |
-
dt_tag.replace_with(h4_tag)
|
54 |
-
|
55 |
-
# 4. HTMLからMarkdownへの一次変換
|
56 |
-
h = html2text.HTML2Text()
|
57 |
-
h.body_width = 0 # テキストの折り返しを無効にする
|
58 |
-
full_markdown_text = h.handle(str(content_div))
|
59 |
-
|
60 |
-
# 5. 生成されたMarkdownから「## 脚注」以降を削除
|
61 |
-
footnote_marker = "\n## 脚注"
|
62 |
-
footnote_index = full_markdown_text.find(footnote_marker)
|
63 |
-
body_text = full_markdown_text[:footnote_index] if footnote_index != -1 else full_markdown_text
|
64 |
-
|
65 |
-
# 6. [編集]リンクを正規表現で一括削除
|
66 |
-
cleaned_body = re.sub(r'\[\[編集\]\(.+?\)]\n', '', body_text)
|
67 |
-
|
68 |
-
# 7. タイトルと整形後の本文を結合
|
69 |
-
final_markdown = f"# {page_title}\n\n{cleaned_body.strip()}"
|
70 |
-
|
71 |
-
return final_markdown
|
72 |
-
|
73 |
-
except requests.exceptions.RequestException as e:
|
74 |
-
return f"HTTPリクエストエラー: {e}"
|
75 |
-
except Exception as e:
|
76 |
-
return f"予期せぬエラーが発生しました: {e}"
|
77 |
-
|
78 |
-
def get_filename_from_url(url):
|
79 |
-
"""URLからファイル名を生成する関数"""
|
80 |
-
try:
|
81 |
-
# URLからページ名を抽出
|
82 |
-
parsed_url = urlparse(url)
|
83 |
-
page_name = parsed_url.path.split('/')[-1]
|
84 |
-
# URLデコード
|
85 |
-
page_name = unquote(page_name)
|
86 |
-
# ファイル名として使用できない文字を置換
|
87 |
-
safe_filename = re.sub(r'[<>:"/\\|?*]', '_', page_name)
|
88 |
-
return f"{safe_filename}.md"
|
89 |
-
except:
|
90 |
-
return "wikipedia_page.md"
|
91 |
|
92 |
-
def
|
93 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
94 |
try:
|
95 |
-
#
|
96 |
-
|
97 |
-
file_path = os.path.join(temp_dir, filename)
|
98 |
|
99 |
-
|
100 |
-
|
|
|
|
|
|
|
|
|
|
|
101 |
|
102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
103 |
except Exception as e:
|
104 |
-
|
105 |
-
return None
|
106 |
|
107 |
-
def
|
108 |
-
"""
|
|
|
|
|
|
|
109 |
try:
|
110 |
-
|
111 |
-
zip_path = os.path.join(temp_dir, zip_filename)
|
112 |
|
113 |
-
|
114 |
-
for file_path in file_paths:
|
115 |
-
if os.path.exists(file_path):
|
116 |
-
# ファイル名のみを取得してZIPに追加
|
117 |
-
filename = os.path.basename(file_path)
|
118 |
-
zipf.write(file_path, filename)
|
119 |
|
120 |
-
|
121 |
-
except Exception as e:
|
122 |
-
print(f"ZIP作成エラー: {e}")
|
123 |
-
return None
|
124 |
-
|
125 |
-
def process_wikipedia_url(url):
|
126 |
-
"""Wikipedia URLを処理してMarkdownを生成するGradio用関数"""
|
127 |
-
if not url:
|
128 |
-
return "URLを入力してください。", None
|
129 |
-
|
130 |
-
# URLが有効かチェック
|
131 |
-
if not url.startswith('http'):
|
132 |
-
return "有効なURLを入力してください(http://またはhttps://から始まるURL)。", None
|
133 |
-
|
134 |
-
# Wikipedia URLかチェック
|
135 |
-
if 'wikipedia.org' not in url:
|
136 |
-
return "WikipediaのURLを入力してください。", None
|
137 |
-
|
138 |
-
# スクレイピングを実行
|
139 |
-
markdown_content = scrape_wikipedia_to_markdown_final(url)
|
140 |
-
|
141 |
-
# ダウンロード用ファイルを作成
|
142 |
-
if not markdown_content.startswith("エラー:") and not markdown_content.startswith("HTTP"):
|
143 |
-
filename = get_filename_from_url(url)
|
144 |
-
file_path = create_download_file(markdown_content, filename)
|
145 |
-
return markdown_content, file_path
|
146 |
-
else:
|
147 |
-
return markdown_content, None
|
148 |
-
|
149 |
-
def process_multiple_urls(urls_text, progress=gr.Progress()):
|
150 |
-
"""複数のWikipedia URLを一括処理してMarkdownを生成する関数"""
|
151 |
-
if not urls_text.strip():
|
152 |
-
return "URLリストを入力してください。", None, [], None
|
153 |
-
|
154 |
-
# URLリストを行ごとに分割
|
155 |
-
urls = [url.strip() for url in urls_text.strip().split('\n') if url.strip()]
|
156 |
-
|
157 |
-
if not urls:
|
158 |
-
return "有効なURLが見つかりませんでした。", None, [], None
|
159 |
-
|
160 |
-
results = []
|
161 |
-
all_content = []
|
162 |
-
individual_files = []
|
163 |
-
total_urls = len(urls)
|
164 |
-
success_count = 0
|
165 |
-
|
166 |
-
for i, url in enumerate(urls):
|
167 |
-
progress((i + 1) / total_urls, f"処理中: {i + 1}/{total_urls}")
|
168 |
|
169 |
-
|
170 |
-
if not url.startswith('http'):
|
171 |
-
results.append(f"❌ 無効なURL: {url}")
|
172 |
-
continue
|
173 |
-
|
174 |
-
if 'wikipedia.org' not in url:
|
175 |
-
results.append(f"❌ Wikipedia以外のURL: {url}")
|
176 |
-
continue
|
177 |
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
page_title = title_match.group(1) if title_match else "不明なページ"
|
187 |
-
|
188 |
-
# 文字数とファイル情報を表示
|
189 |
-
char_count = len(markdown_content)
|
190 |
-
filename = get_filename_from_url(url)
|
191 |
-
|
192 |
-
results.append(f"✅ 処理成功: {url}")
|
193 |
-
results.append(f" 📄 ページタイトル: {page_title}")
|
194 |
-
results.append(f" 📊 文字数: {char_count:,} 文字")
|
195 |
-
results.append(f" 💾 ファイル名: {filename}")
|
196 |
-
|
197 |
-
all_content.append(markdown_content)
|
198 |
-
success_count += 1
|
199 |
-
|
200 |
-
# 個別ファイルを作成
|
201 |
-
file_path = create_download_file(markdown_content, filename)
|
202 |
-
if file_path:
|
203 |
-
individual_files.append(file_path)
|
204 |
-
except Exception as e:
|
205 |
-
results.append(f"❌ 処理エラー: {url}")
|
206 |
-
results.append(f" エラー内容: {str(e)}")
|
207 |
-
|
208 |
-
# サマリー情報を追加
|
209 |
-
summary = [
|
210 |
-
"=" * 60,
|
211 |
-
"📊 処理結果サマリー",
|
212 |
-
"=" * 60,
|
213 |
-
f"🔗 処理対象URL数: {total_urls}",
|
214 |
-
f"✅ 成功: {success_count}",
|
215 |
-
f"❌ 失敗: {total_urls - success_count}",
|
216 |
-
""
|
217 |
-
]
|
218 |
-
|
219 |
-
# 結果を結合
|
220 |
-
final_result = "\n".join(summary + results)
|
221 |
-
|
222 |
-
# 一括ダウンロード用ファイルを作成
|
223 |
-
batch_file_path = None
|
224 |
-
if all_content:
|
225 |
-
combined_content = "\n\n" + "="*80 + "\n\n".join(all_content)
|
226 |
-
batch_file_path = create_download_file(combined_content, "wikipedia_batch_export.md")
|
227 |
-
|
228 |
-
# ZIPファイルを作成
|
229 |
-
zip_file_path = None
|
230 |
-
if individual_files:
|
231 |
-
zip_file_path = create_zip_file(individual_files, "wikipedia_export.zip")
|
232 |
-
|
233 |
-
return final_result, batch_file_path, individual_files, zip_file_path
|
234 |
|
235 |
# Gradioインターフェースの作成
|
236 |
def create_interface():
|
237 |
"""Gradioインターフェースを作成する関数"""
|
238 |
theme = create_zen_theme()
|
239 |
|
240 |
-
with gr.Blocks(theme=theme, title="
|
241 |
# ヘッダー
|
242 |
gr.HTML("""
|
243 |
<div style='text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, #d4a574 0%, #ffffff 50%, #f5f2ed 100%); color: #3d405b; border-radius: 12px;'>
|
244 |
-
<h1 style='font-size: 3rem; margin-bottom: 0.5rem; text-shadow: 1px 1px 2px rgba(0,0,0,0.1);'
|
245 |
-
<p style='font-size: 1.2rem; opacity: 0.8;'>
|
246 |
</div>
|
247 |
""")
|
248 |
|
249 |
# タブの作成
|
250 |
with gr.Tabs():
|
251 |
# 単体処理タブ
|
252 |
-
with gr.TabItem("
|
253 |
with gr.Row():
|
254 |
with gr.Column(scale=1):
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
259 |
)
|
260 |
-
|
|
|
|
|
261 |
|
262 |
with gr.Column(scale=1):
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
show_copy_button=True
|
268 |
)
|
269 |
-
|
270 |
-
|
271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
272 |
)
|
273 |
-
|
274 |
-
# ボタンクリック時の処理
|
275 |
-
def update_single_output(url):
|
276 |
-
content, file_path = process_wikipedia_url(url)
|
277 |
-
if file_path:
|
278 |
-
return content, gr.update(value=file_path, visible=True)
|
279 |
-
else:
|
280 |
-
return content, gr.update(visible=False)
|
281 |
-
|
282 |
-
convert_btn.click(
|
283 |
-
fn=update_single_output,
|
284 |
-
inputs=url_input,
|
285 |
-
outputs=[output_text, download_file]
|
286 |
-
)
|
287 |
-
|
288 |
-
# 使用例
|
289 |
-
def example_process(url):
|
290 |
-
content, _ = process_wikipedia_url(url)
|
291 |
-
return content
|
292 |
-
|
293 |
-
gr.Examples(
|
294 |
-
examples=[
|
295 |
-
["https://ja.wikipedia.org/wiki/Python"],
|
296 |
-
["https://ja.wikipedia.org/wiki/JavaScript"],
|
297 |
-
["https://ja.wikipedia.org/wiki/HTML"]
|
298 |
-
],
|
299 |
-
inputs=url_input,
|
300 |
-
outputs=output_text,
|
301 |
-
fn=example_process,
|
302 |
-
cache_examples=False
|
303 |
-
)
|
304 |
|
305 |
-
#
|
306 |
-
with gr.TabItem("
|
307 |
with gr.Row():
|
308 |
with gr.Column(scale=1):
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
value="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
314 |
)
|
315 |
-
|
|
|
316 |
|
317 |
with gr.Column(scale=1):
|
318 |
-
|
319 |
-
|
|
|
320 |
lines=15,
|
321 |
-
max_lines=30,
|
322 |
show_copy_button=True
|
323 |
)
|
324 |
-
batch_download_file = gr.File(
|
325 |
-
label="📥 全体をまとめてダウンロード",
|
326 |
-
visible=False
|
327 |
-
)
|
328 |
-
zip_download_file = gr.File(
|
329 |
-
label="🗜️ ZIPファイルでダウンロード",
|
330 |
-
visible=False
|
331 |
-
)
|
332 |
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
|
|
|
|
342 |
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
# 戻り値のリストを準備
|
348 |
-
outputs = [content]
|
349 |
-
|
350 |
-
# 一括ダウンロードファイル
|
351 |
-
if batch_file_path:
|
352 |
-
outputs.append(gr.update(value=batch_file_path, visible=True))
|
353 |
-
else:
|
354 |
-
outputs.append(gr.update(visible=False))
|
355 |
-
|
356 |
-
# ZIPダウンロードファイル
|
357 |
-
if zip_file_path:
|
358 |
-
outputs.append(gr.update(value=zip_file_path, visible=True))
|
359 |
-
else:
|
360 |
-
outputs.append(gr.update(visible=False))
|
361 |
-
|
362 |
-
# 個別ダウンロードエリアの表示/非表示
|
363 |
-
if individual_files:
|
364 |
-
outputs.append(gr.update(visible=True))
|
365 |
-
else:
|
366 |
-
outputs.append(gr.update(visible=False))
|
367 |
-
|
368 |
-
# 個別ファイル(最大5つまで表示)
|
369 |
-
for i in range(5):
|
370 |
-
if i < len(individual_files):
|
371 |
-
filename = os.path.basename(individual_files[i])
|
372 |
-
outputs.append(gr.update(value=individual_files[i], visible=True, label=f"📄 {filename}"))
|
373 |
-
else:
|
374 |
-
outputs.append(gr.update(visible=False))
|
375 |
-
|
376 |
-
return outputs
|
377 |
|
378 |
-
|
379 |
-
fn=
|
380 |
-
inputs=
|
381 |
-
outputs=
|
382 |
-
batch_output_text,
|
383 |
-
batch_download_file,
|
384 |
-
zip_download_file,
|
385 |
-
individual_downloads,
|
386 |
-
individual_file_1,
|
387 |
-
individual_file_2,
|
388 |
-
individual_file_3,
|
389 |
-
individual_file_4,
|
390 |
-
individual_file_5
|
391 |
-
]
|
392 |
)
|
393 |
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
399 |
|
|
|
400 |
gr.Markdown("---")
|
401 |
-
gr.Markdown("### 🎯
|
402 |
-
|
403 |
-
gr.
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
410 |
|
411 |
# ZENテーマの説明
|
412 |
gr.HTML("""
|
413 |
<div style='text-align: center; margin-top: 2rem; padding: 1.5rem; background: #ffffff; border-radius: 12px;'>
|
414 |
<h3 style='color: #3d405b; margin-top: 0;'>🧘♀️ ZENテーマ</h3>
|
415 |
-
<p style='color: #8b7355;'
|
|
|
416 |
</div>
|
417 |
""")
|
418 |
|
|
|
|
|
|
|
|
|
|
|
1 |
import gradio as gr
|
2 |
from theme import create_zen_theme
|
3 |
+
from src.frame_bridge import FrameBridge, BatchProcessor
|
4 |
import os
|
|
|
|
|
5 |
|
6 |
+
# Frame Bridge インスタンスを作成
|
7 |
+
frame_bridge = FrameBridge(exclude_edge_frames=True)
|
8 |
+
batch_processor = BatchProcessor(exclude_edge_frames=True)
|
|
|
9 |
|
10 |
+
def process_sample_videos():
|
11 |
+
"""サンプル動画を処理する関数"""
|
12 |
+
video1_path = "examples/assets/example/REI/input/REI-001.mp4"
|
13 |
+
video2_path = "examples/assets/example/REI/input/REI-002.mp4"
|
14 |
+
|
15 |
+
if not os.path.exists(video1_path) or not os.path.exists(video2_path):
|
16 |
+
return "サンプル動画ファイルが見つかりません。", None, None, None, 0.0
|
17 |
+
|
18 |
+
return frame_bridge.process_video_bridge(video1_path, video2_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
+
def process_batch_videos(input_folder, output_folder, mode, filename):
|
21 |
+
"""バッチ動画処理関数"""
|
22 |
+
if not input_folder or not os.path.exists(input_folder):
|
23 |
+
return "入力フォルダが指定されていないか、存在しません。", None
|
24 |
+
|
25 |
+
if not output_folder:
|
26 |
+
output_folder = "output"
|
27 |
+
|
28 |
try:
|
29 |
+
# バッチプロセッサを初期化
|
30 |
+
processor = BatchProcessor(output_dir=output_folder, exclude_edge_frames=True)
|
|
|
31 |
|
32 |
+
if mode == "順次結合":
|
33 |
+
success, final_output, results = processor.process_sequential_merge(input_folder, filename or "merged_sequence.mp4")
|
34 |
+
if success:
|
35 |
+
report = processor.generate_report(results)
|
36 |
+
return f"✅ 順次結合完了!\n📁 出力: {final_output}\n\n{report}", final_output
|
37 |
+
else:
|
38 |
+
return "❌ 順次結合に失敗しました", None
|
39 |
|
40 |
+
elif mode == "ペア結合":
|
41 |
+
success, output_files, results = processor.process_pairwise_merge(input_folder)
|
42 |
+
if success:
|
43 |
+
report = processor.generate_report(results)
|
44 |
+
# 最初の出力ファイルを返す(複数ある場合)
|
45 |
+
first_output = output_files[0] if output_files else None
|
46 |
+
return f"✅ ペア結合完了!\n📁 出力ファイル数: {len(output_files)}\n\n{report}", first_output
|
47 |
+
else:
|
48 |
+
return "❌ ペア結合に失敗しました", None
|
49 |
+
|
50 |
except Exception as e:
|
51 |
+
return f"処理エラー: {str(e)}", None
|
|
|
52 |
|
53 |
+
def process_video_bridge(video1, video2, progress=gr.Progress()):
|
54 |
+
"""2つの動画を分析して最適な結合点を見つけ、結合する関数"""
|
55 |
+
if video1 is None or video2 is None:
|
56 |
+
return "2つの動画ファイルをアップロードしてください。", None, None, None, None
|
57 |
+
|
58 |
try:
|
59 |
+
progress(0.1, "動画を分析中...")
|
|
|
60 |
|
61 |
+
result_text, output_path, frame1_path, frame2_path, similarity = frame_bridge.process_video_bridge(video1, video2)
|
|
|
|
|
|
|
|
|
|
|
62 |
|
63 |
+
progress(1.0, "完了!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
|
65 |
+
return result_text, output_path, frame1_path, frame2_path, similarity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
66 |
|
67 |
+
except Exception as e:
|
68 |
+
return f"処理エラー: {str(e)}", None, None, None, None
|
69 |
+
|
70 |
+
def analyze_video_details(video_path):
|
71 |
+
"""動画の詳細情報を分析する関数"""
|
72 |
+
if video_path is None:
|
73 |
+
return ""
|
74 |
+
return frame_bridge.processor.analyze_video_details(video_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
75 |
|
76 |
# Gradioインターフェースの作成
|
77 |
def create_interface():
|
78 |
"""Gradioインターフェースを作成する関数"""
|
79 |
theme = create_zen_theme()
|
80 |
|
81 |
+
with gr.Blocks(theme=theme, title="Frame Bridge - 動画フレーム結合アプリ") as demo:
|
82 |
# ヘッダー
|
83 |
gr.HTML("""
|
84 |
<div style='text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, #d4a574 0%, #ffffff 50%, #f5f2ed 100%); color: #3d405b; border-radius: 12px;'>
|
85 |
+
<h1 style='font-size: 3rem; margin-bottom: 0.5rem; text-shadow: 1px 1px 2px rgba(0,0,0,0.1);'>🎬 Frame Bridge</h1>
|
86 |
+
<p style='font-size: 1.2rem; opacity: 0.8;'>2つの動画を最適なフレームで自動結合するAIアプリ</p>
|
87 |
</div>
|
88 |
""")
|
89 |
|
90 |
# タブの作成
|
91 |
with gr.Tabs():
|
92 |
# 単体処理タブ
|
93 |
+
with gr.TabItem("🎥 単体処理"):
|
94 |
with gr.Row():
|
95 |
with gr.Column(scale=1):
|
96 |
+
gr.Markdown("### 📹 動画アップロード")
|
97 |
+
video1_input = gr.Video(
|
98 |
+
label="🎥 動画1(前半)",
|
99 |
+
height=300
|
100 |
+
)
|
101 |
+
video1_info = gr.Textbox(
|
102 |
+
label="📊 動画1の情報",
|
103 |
+
lines=6,
|
104 |
+
interactive=False
|
105 |
+
)
|
106 |
+
|
107 |
+
video2_input = gr.Video(
|
108 |
+
label="🎥 動画2(後半)",
|
109 |
+
height=300
|
110 |
+
)
|
111 |
+
video2_info = gr.Textbox(
|
112 |
+
label="📊 動画2の情報",
|
113 |
+
lines=6,
|
114 |
+
interactive=False
|
115 |
)
|
116 |
+
|
117 |
+
bridge_btn = gr.Button("🌉 フレームブリッジ実行", variant="primary", size="lg")
|
118 |
+
sample_btn = gr.Button("🎬 サンプル動画で試す", variant="secondary", size="lg")
|
119 |
|
120 |
with gr.Column(scale=1):
|
121 |
+
gr.Markdown("### 🎯 結合結果")
|
122 |
+
result_text = gr.Textbox(
|
123 |
+
label="📝 分析結果",
|
124 |
+
lines=10,
|
125 |
show_copy_button=True
|
126 |
)
|
127 |
+
|
128 |
+
merged_video = gr.Video(
|
129 |
+
label="🎬 結合された動画",
|
130 |
+
height=300
|
131 |
+
)
|
132 |
+
|
133 |
+
# 接続フレーム表示
|
134 |
+
with gr.Row():
|
135 |
+
connection_frame1 = gr.Image(
|
136 |
+
label="🔗 動画1の接続フレーム",
|
137 |
+
height=200
|
138 |
+
)
|
139 |
+
connection_frame2 = gr.Image(
|
140 |
+
label="🔗 動画2の接続フレーム",
|
141 |
+
height=200
|
142 |
+
)
|
143 |
+
|
144 |
+
similarity_score = gr.Number(
|
145 |
+
label="📈 フレーム類似度スコア",
|
146 |
+
precision=3
|
147 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
148 |
|
149 |
+
# バッチ処理タブ
|
150 |
+
with gr.TabItem("📁 バッチ処理"):
|
151 |
with gr.Row():
|
152 |
with gr.Column(scale=1):
|
153 |
+
gr.Markdown("### 📂 フォルダ指定")
|
154 |
+
input_folder = gr.Textbox(
|
155 |
+
label="📥 入力フォルダパス",
|
156 |
+
placeholder="例: examples/assets/example/REI/input",
|
157 |
+
value="examples/assets/example/REI/input"
|
158 |
+
)
|
159 |
+
output_folder = gr.Textbox(
|
160 |
+
label="📤 出力フォルダパス",
|
161 |
+
placeholder="例: examples/assets/example/REI/output",
|
162 |
+
value="examples/assets/example/REI/output"
|
163 |
+
)
|
164 |
+
|
165 |
+
processing_mode = gr.Radio(
|
166 |
+
label="🔄 処理モード",
|
167 |
+
choices=["順次結合", "ペア結合"],
|
168 |
+
value="順次結合",
|
169 |
+
info="順次結合: 全動画を1つに結合 / ペア結合: 2つずつペアで結合"
|
170 |
+
)
|
171 |
+
|
172 |
+
output_filename = gr.Textbox(
|
173 |
+
label="📄 出力ファイル名 (順次結合のみ)",
|
174 |
+
placeholder="REI_merged_sequence.mp4",
|
175 |
+
value="REI_merged_sequence.mp4"
|
176 |
)
|
177 |
+
|
178 |
+
batch_btn = gr.Button("🚀 バッチ処理実行", variant="primary", size="lg")
|
179 |
|
180 |
with gr.Column(scale=1):
|
181 |
+
gr.Markdown("### 📊 処理結果")
|
182 |
+
batch_result = gr.Textbox(
|
183 |
+
label="📝 バッチ処理結果",
|
184 |
lines=15,
|
|
|
185 |
show_copy_button=True
|
186 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
187 |
|
188 |
+
batch_output = gr.Video(
|
189 |
+
label="🎬 出力動画(プレビュー)",
|
190 |
+
height=300
|
191 |
+
)
|
192 |
+
|
193 |
+
|
194 |
+
# 動画情報の自動更新
|
195 |
+
def update_video1_info(video):
|
196 |
+
if video is None:
|
197 |
+
return ""
|
198 |
+
return analyze_video_details(video)
|
199 |
|
200 |
+
def update_video2_info(video):
|
201 |
+
if video is None:
|
202 |
+
return ""
|
203 |
+
return analyze_video_details(video)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
204 |
|
205 |
+
video1_input.change(
|
206 |
+
fn=update_video1_info,
|
207 |
+
inputs=video1_input,
|
208 |
+
outputs=video1_info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
209 |
)
|
210 |
|
211 |
+
video2_input.change(
|
212 |
+
fn=update_video2_info,
|
213 |
+
inputs=video2_input,
|
214 |
+
outputs=video2_info
|
215 |
+
)
|
216 |
+
|
217 |
+
# メイン処理
|
218 |
+
bridge_btn.click(
|
219 |
+
fn=process_video_bridge,
|
220 |
+
inputs=[video1_input, video2_input],
|
221 |
+
outputs=[result_text, merged_video, connection_frame1, connection_frame2, similarity_score]
|
222 |
+
)
|
223 |
+
|
224 |
+
# サンプル動画処理
|
225 |
+
sample_btn.click(
|
226 |
+
fn=process_sample_videos,
|
227 |
+
inputs=[],
|
228 |
+
outputs=[result_text, merged_video, connection_frame1, connection_frame2, similarity_score]
|
229 |
+
)
|
230 |
+
|
231 |
+
# バッチ処理
|
232 |
+
batch_btn.click(
|
233 |
+
fn=process_batch_videos,
|
234 |
+
inputs=[input_folder, output_folder, processing_mode, output_filename],
|
235 |
+
outputs=[batch_result, batch_output]
|
236 |
+
)
|
237 |
|
238 |
+
# 使用方法の説明
|
239 |
gr.Markdown("---")
|
240 |
+
gr.Markdown("### 🎯 使用方法")
|
241 |
+
|
242 |
+
with gr.Tabs():
|
243 |
+
with gr.TabItem("🎥 単体処理"):
|
244 |
+
gr.Markdown("1. **動画1(前半)**: 結合したい最初の動画をアップロード")
|
245 |
+
gr.Markdown("2. **動画2(後半)**: 結合したい2番目の動画をアップロード")
|
246 |
+
gr.Markdown("3. **フレームブリッジ実行**: AIが最適な接続点を自動検出して結合")
|
247 |
+
gr.Markdown("4. **サンプル動画で試す**: assetsフォルダのサンプル動画で機能をテスト")
|
248 |
+
|
249 |
+
with gr.TabItem("📁 バッチ処理"):
|
250 |
+
gr.Markdown("1. **入力フォルダ**: 動画ファイルが格納されたフォルダパスを指定")
|
251 |
+
gr.Markdown("2. **出力フォルダ**: 結合結果を保存するフォルダパスを指定")
|
252 |
+
gr.Markdown("3. **処理モード選択**:")
|
253 |
+
gr.Markdown(" - **順次結合**: フォルダ内の全動画を名前順に1つの動画に結合")
|
254 |
+
gr.Markdown(" - **ペア結合**: 動画を2つずつペアにして結合(複数の出力ファイル)")
|
255 |
+
gr.Markdown("4. **出力ファイル名**: 順次結合の場合の最終ファイル名を指定")
|
256 |
+
gr.Markdown("5. **バッチ処理実行**: 指定した設定で一括処理を開始")
|
257 |
+
|
258 |
+
gr.Markdown("### 🔬 技術的特徴")
|
259 |
+
gr.Markdown("- **SSIM(構造的類似性指標)**: フレーム間の視覚的類似度を高精度で計算")
|
260 |
+
gr.Markdown("- **自動最適化**: 動画1の終了部分と動画2の開始部分から最適な接続点を検出")
|
261 |
+
gr.Markdown("- **スムーズな結合**: 視覚的に自然な動画結合を実現")
|
262 |
+
gr.Markdown("- **バッチ処理**: 複数動画の自動処理とレポート生成")
|
263 |
+
gr.Markdown("- **ファイル名ソート**: 自然順序でのファイル名ソートによる正確な順序処理")
|
264 |
|
265 |
# ZENテーマの説明
|
266 |
gr.HTML("""
|
267 |
<div style='text-align: center; margin-top: 2rem; padding: 1.5rem; background: #ffffff; border-radius: 12px;'>
|
268 |
<h3 style='color: #3d405b; margin-top: 0;'>🧘♀️ ZENテーマ</h3>
|
269 |
+
<p style='color: #8b7355;'>和モダンなデザインで、直感的な動画編集体験を提供</p>
|
270 |
+
<p style='color: #8b7355; font-size: 0.9rem;'>単体処理とバッチ処理の両方に対応した高機能動画結合アプリ</p>
|
271 |
</div>
|
272 |
""")
|
273 |
|
pyproject.toml
ADDED
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[build-system]
|
2 |
+
requires = ["setuptools>=61.0", "wheel"]
|
3 |
+
build-backend = "setuptools.build_meta"
|
4 |
+
|
5 |
+
[project]
|
6 |
+
name = "frame-bridge"
|
7 |
+
version = "1.0.0"
|
8 |
+
description = "AI-powered video frame bridging application using SSIM technology"
|
9 |
+
readme = "README.md"
|
10 |
+
license = {text = "MIT"}
|
11 |
+
authors = [
|
12 |
+
{name = "Sunwood AI Labs", email = "[email protected]"}
|
13 |
+
]
|
14 |
+
maintainers = [
|
15 |
+
{name = "Sunwood AI Labs", email = "[email protected]"}
|
16 |
+
]
|
17 |
+
keywords = [
|
18 |
+
"video-processing",
|
19 |
+
"ai",
|
20 |
+
"computer-vision",
|
21 |
+
"gradio",
|
22 |
+
"opencv",
|
23 |
+
"ssim",
|
24 |
+
"frame-analysis",
|
25 |
+
"video-editing"
|
26 |
+
]
|
27 |
+
classifiers = [
|
28 |
+
"Development Status :: 4 - Beta",
|
29 |
+
"Intended Audience :: Developers",
|
30 |
+
"Intended Audience :: End Users/Desktop",
|
31 |
+
"License :: OSI Approved :: MIT License",
|
32 |
+
"Operating System :: OS Independent",
|
33 |
+
"Programming Language :: Python :: 3",
|
34 |
+
|
35 |
+
"Programming Language :: Python :: 3.10",
|
36 |
+
"Programming Language :: Python :: 3.11",
|
37 |
+
"Topic :: Multimedia :: Video",
|
38 |
+
"Topic :: Multimedia :: Video :: Display",
|
39 |
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
40 |
+
"Topic :: Scientific/Engineering :: Image Processing",
|
41 |
+
]
|
42 |
+
requires-python = ">=3.10"
|
43 |
+
dependencies = [
|
44 |
+
"opencv-python>=4.8.0",
|
45 |
+
"numpy>=1.24.0",
|
46 |
+
"pillow>=10.0.0",
|
47 |
+
"gradio>=5.42.0",
|
48 |
+
"scikit-image>=0.21.0",
|
49 |
+
]
|
50 |
+
|
51 |
+
[project.optional-dependencies]
|
52 |
+
dev = [
|
53 |
+
"pytest>=7.0.0",
|
54 |
+
"pytest-cov>=4.0.0",
|
55 |
+
"black>=23.0.0",
|
56 |
+
"flake8>=6.0.0",
|
57 |
+
"mypy>=1.0.0",
|
58 |
+
"pre-commit>=3.0.0",
|
59 |
+
]
|
60 |
+
docs = [
|
61 |
+
"sphinx>=6.0.0",
|
62 |
+
"sphinx-rtd-theme>=1.2.0",
|
63 |
+
"myst-parser>=1.0.0",
|
64 |
+
]
|
65 |
+
|
66 |
+
[project.urls]
|
67 |
+
Homepage = "https://github.com/Sunwood-ai-labsII/frame-bridge"
|
68 |
+
Repository = "https://github.com/Sunwood-ai-labsII/frame-bridge.git"
|
69 |
+
Documentation = "https://github.com/Sunwood-ai-labsII/frame-bridge#readme"
|
70 |
+
"Bug Tracker" = "https://github.com/Sunwood-ai-labsII/frame-bridge/issues"
|
71 |
+
"Demo Site" = "https://huggingface.co/spaces/MakiAi/frame-bridge"
|
72 |
+
|
73 |
+
[project.scripts]
|
74 |
+
frame-bridge = "app:main"
|
75 |
+
frame-bridge-test = "test_sample:main"
|
76 |
+
frame-bridge-batch = "batch_test:main"
|
77 |
+
|
78 |
+
[tool.setuptools.packages.find]
|
79 |
+
where = ["."]
|
80 |
+
include = ["*"]
|
81 |
+
exclude = ["tests*", "docs*", ".github*"]
|
82 |
+
|
83 |
+
[tool.setuptools.package-data]
|
84 |
+
"*" = ["*.md", "*.txt", "*.yml", "*.yaml"]
|
85 |
+
|
86 |
+
[tool.black]
|
87 |
+
line-length = 88
|
88 |
+
target-version = ['py38', 'py39', 'py310', 'py311']
|
89 |
+
include = '\.pyi?$'
|
90 |
+
extend-exclude = '''
|
91 |
+
/(
|
92 |
+
# directories
|
93 |
+
\.eggs
|
94 |
+
| \.git
|
95 |
+
| \.hg
|
96 |
+
| \.mypy_cache
|
97 |
+
| \.tox
|
98 |
+
| \.venv
|
99 |
+
| build
|
100 |
+
| dist
|
101 |
+
)/
|
102 |
+
'''
|
103 |
+
|
104 |
+
[tool.mypy]
|
105 |
+
python_version = "3.8"
|
106 |
+
warn_return_any = true
|
107 |
+
warn_unused_configs = true
|
108 |
+
disallow_untyped_defs = true
|
109 |
+
disallow_incomplete_defs = true
|
110 |
+
check_untyped_defs = true
|
111 |
+
disallow_untyped_decorators = true
|
112 |
+
no_implicit_optional = true
|
113 |
+
warn_redundant_casts = true
|
114 |
+
warn_unused_ignores = true
|
115 |
+
warn_no_return = true
|
116 |
+
warn_unreachable = true
|
117 |
+
strict_equality = true
|
118 |
+
|
119 |
+
[tool.pytest.ini_options]
|
120 |
+
minversion = "7.0"
|
121 |
+
addopts = "-ra -q --strict-markers --strict-config"
|
122 |
+
testpaths = ["tests"]
|
123 |
+
python_files = ["test_*.py", "*_test.py"]
|
124 |
+
python_classes = ["Test*"]
|
125 |
+
python_functions = ["test_*"]
|
126 |
+
|
127 |
+
[tool.coverage.run]
|
128 |
+
source = ["."]
|
129 |
+
omit = [
|
130 |
+
"tests/*",
|
131 |
+
"setup.py",
|
132 |
+
"*/site-packages/*",
|
133 |
+
".venv/*",
|
134 |
+
"venv/*",
|
135 |
+
]
|
136 |
+
|
137 |
+
[tool.coverage.report]
|
138 |
+
exclude_lines = [
|
139 |
+
"pragma: no cover",
|
140 |
+
"def __repr__",
|
141 |
+
"if self.debug:",
|
142 |
+
"if settings.DEBUG",
|
143 |
+
"raise AssertionError",
|
144 |
+
"raise NotImplementedError",
|
145 |
+
"if 0:",
|
146 |
+
"if __name__ == .__main__.:",
|
147 |
+
"class .*\\bProtocol\\):",
|
148 |
+
"@(abc\\.)?abstractmethod",
|
149 |
+
]
|
150 |
+
|
151 |
+
[tool.flake8]
|
152 |
+
max-line-length = 88
|
153 |
+
extend-ignore = ["E203", "W503"]
|
154 |
+
exclude = [
|
155 |
+
".git",
|
156 |
+
"__pycache__",
|
157 |
+
"docs/source/conf.py",
|
158 |
+
"old",
|
159 |
+
"build",
|
160 |
+
"dist",
|
161 |
+
".venv",
|
162 |
+
"venv",
|
163 |
+
]
|
requirements.txt
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
gradio>=5.42.0
|
|
|
|
1 |
+
opencv-python>=4.8.0
|
2 |
+
numpy>=1.24.0
|
3 |
+
pillow>=10.0.0
|
4 |
gradio>=5.42.0
|
5 |
+
scikit-image>=0.21.0
|
scripts/show_structure.py
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Frame Bridge - Project Structure Display
|
3 |
+
プロジェクト構造を表示するスクリプト
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
from pathlib import Path
|
8 |
+
|
9 |
+
def show_tree(directory, prefix="", max_depth=3, current_depth=0):
|
10 |
+
"""ディレクトリツリーを表示"""
|
11 |
+
if current_depth > max_depth:
|
12 |
+
return
|
13 |
+
|
14 |
+
directory = Path(directory)
|
15 |
+
if not directory.exists():
|
16 |
+
return
|
17 |
+
|
18 |
+
items = sorted(directory.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
|
19 |
+
|
20 |
+
for i, item in enumerate(items):
|
21 |
+
is_last = i == len(items) - 1
|
22 |
+
current_prefix = "└── " if is_last else "├── "
|
23 |
+
|
24 |
+
if item.is_dir():
|
25 |
+
print(f"{prefix}{current_prefix}{item.name}/")
|
26 |
+
extension = " " if is_last else "│ "
|
27 |
+
show_tree(item, prefix + extension, max_depth, current_depth + 1)
|
28 |
+
else:
|
29 |
+
print(f"{prefix}{current_prefix}{item.name}")
|
30 |
+
|
31 |
+
def main():
|
32 |
+
"""メイン処理"""
|
33 |
+
print("🎬 Frame Bridge - プロジェクト構造")
|
34 |
+
print("=" * 60)
|
35 |
+
|
36 |
+
# プロジェクトルートから表示
|
37 |
+
project_root = Path(__file__).parent.parent
|
38 |
+
os.chdir(project_root)
|
39 |
+
|
40 |
+
print("📁 プロジェクト構造:")
|
41 |
+
print("frame-bridge/")
|
42 |
+
show_tree(".", max_depth=3)
|
43 |
+
|
44 |
+
print("\n" + "=" * 60)
|
45 |
+
print("📊 主要コンポーネント:")
|
46 |
+
print("• src/frame_bridge/ - メインライブラリ")
|
47 |
+
print("• scripts/ - 実行スクリプト")
|
48 |
+
print("• tests/ - テストファイル")
|
49 |
+
print("• examples/ - サンプルデータ")
|
50 |
+
print("• docs/ - ドキュメント")
|
51 |
+
|
52 |
+
print("\n🎯 新機能:")
|
53 |
+
print("• エッジフレーム除外オプション")
|
54 |
+
print("• 最適化されたフォルダ構造")
|
55 |
+
print("• 設定管理システム")
|
56 |
+
|
57 |
+
if __name__ == "__main__":
|
58 |
+
main()
|
src/frame_bridge/__init__.py
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Frame Bridge - AI-powered video frame bridging application
|
3 |
+
2つの動画を最適なフレームで自動結合するAIアプリケーション
|
4 |
+
"""
|
5 |
+
|
6 |
+
__version__ = "1.0.0"
|
7 |
+
__author__ = "Sunwood AI Labs"
|
8 |
+
__email__ = "[email protected]"
|
9 |
+
|
10 |
+
from .video_processor import VideoProcessor, FrameBridge
|
11 |
+
from .batch_processor import BatchProcessor
|
12 |
+
|
13 |
+
__all__ = [
|
14 |
+
"VideoProcessor",
|
15 |
+
"FrameBridge",
|
16 |
+
"BatchProcessor",
|
17 |
+
"__version__",
|
18 |
+
"__author__",
|
19 |
+
"__email__"
|
20 |
+
]
|
src/frame_bridge/batch_processor.py
ADDED
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Frame Bridge - Batch Processing Module
|
3 |
+
フォルダ内の動画ファイルを順次結合するバッチ処理モジュール
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
import glob
|
8 |
+
import logging
|
9 |
+
from pathlib import Path
|
10 |
+
from typing import List, Tuple, Optional
|
11 |
+
from .video_processor import FrameBridge
|
12 |
+
|
13 |
+
# ログ設定
|
14 |
+
logging.basicConfig(level=logging.INFO)
|
15 |
+
logger = logging.getLogger(__name__)
|
16 |
+
|
17 |
+
|
18 |
+
class BatchProcessor:
|
19 |
+
"""バッチ処理を行うクラス"""
|
20 |
+
|
21 |
+
def __init__(self, output_dir: str = "output", exclude_edge_frames: bool = True):
|
22 |
+
"""
|
23 |
+
初期化
|
24 |
+
|
25 |
+
Args:
|
26 |
+
output_dir: 出力ディレクトリ
|
27 |
+
exclude_edge_frames: 最初と最後のフレームを除外するかどうか
|
28 |
+
"""
|
29 |
+
self.frame_bridge = FrameBridge(exclude_edge_frames=exclude_edge_frames)
|
30 |
+
self.output_dir = Path(output_dir)
|
31 |
+
self.output_dir.mkdir(exist_ok=True)
|
32 |
+
self.exclude_edge_frames = exclude_edge_frames
|
33 |
+
|
34 |
+
# サポートする動画形式
|
35 |
+
self.supported_formats = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm']
|
36 |
+
|
37 |
+
def get_video_files(self, input_dir: str) -> List[str]:
|
38 |
+
"""
|
39 |
+
指定ディレクトリから動画ファイルを取得し、名前順にソート
|
40 |
+
|
41 |
+
Args:
|
42 |
+
input_dir: 入力ディレクトリ
|
43 |
+
|
44 |
+
Returns:
|
45 |
+
ソートされた動画ファイルのリスト
|
46 |
+
"""
|
47 |
+
input_path = Path(input_dir)
|
48 |
+
if not input_path.exists():
|
49 |
+
logger.error(f"入力ディレクトリが存在しません: {input_dir}")
|
50 |
+
return []
|
51 |
+
|
52 |
+
video_files = []
|
53 |
+
for ext in self.supported_formats:
|
54 |
+
pattern = str(input_path / f"*{ext}")
|
55 |
+
video_files.extend(glob.glob(pattern))
|
56 |
+
|
57 |
+
# ファイル名でソート(自然順序)
|
58 |
+
video_files.sort(key=lambda x: os.path.basename(x).lower())
|
59 |
+
|
60 |
+
logger.info(f"検出された動画ファイル数: {len(video_files)}")
|
61 |
+
for i, file in enumerate(video_files):
|
62 |
+
logger.info(f" {i+1}. {os.path.basename(file)}")
|
63 |
+
|
64 |
+
return video_files
|
65 |
+
|
66 |
+
def process_sequential_merge(self, input_dir: str, output_filename: str = "merged_sequence.mp4") -> Tuple[bool, str, List[dict]]:
|
67 |
+
"""
|
68 |
+
フォルダ内の動画を順次結合
|
69 |
+
|
70 |
+
Args:
|
71 |
+
input_dir: 入力ディレクトリ
|
72 |
+
output_filename: 出力ファイル名
|
73 |
+
|
74 |
+
Returns:
|
75 |
+
Tuple[成功フラグ, 最終出力パス, 処理結果リスト]
|
76 |
+
"""
|
77 |
+
video_files = self.get_video_files(input_dir)
|
78 |
+
|
79 |
+
if len(video_files) < 2:
|
80 |
+
return False, "", [{"error": "結合には最低2つの動画ファイルが必要です"}]
|
81 |
+
|
82 |
+
results = []
|
83 |
+
current_video = video_files[0]
|
84 |
+
|
85 |
+
logger.info(f"順次結合処理開始: {len(video_files)}個のファイル")
|
86 |
+
|
87 |
+
for i in range(1, len(video_files)):
|
88 |
+
next_video = video_files[i]
|
89 |
+
|
90 |
+
logger.info(f"結合 {i}/{len(video_files)-1}: {os.path.basename(current_video)} + {os.path.basename(next_video)}")
|
91 |
+
|
92 |
+
# 中間出力ファイル名
|
93 |
+
if i == len(video_files) - 1:
|
94 |
+
# 最後の結合は最終ファイル名
|
95 |
+
temp_output = self.output_dir / output_filename
|
96 |
+
else:
|
97 |
+
# 中間ファイル
|
98 |
+
temp_output = self.output_dir / f"temp_merge_{i}.mp4"
|
99 |
+
|
100 |
+
# 結合処理
|
101 |
+
result_text, output_path, frame1_path, frame2_path, similarity = self.frame_bridge.process_video_bridge(
|
102 |
+
current_video, next_video
|
103 |
+
)
|
104 |
+
|
105 |
+
if output_path and os.path.exists(output_path):
|
106 |
+
# 結果を指定の場所に移動
|
107 |
+
import shutil
|
108 |
+
shutil.move(output_path, str(temp_output))
|
109 |
+
|
110 |
+
result_info = {
|
111 |
+
"step": i,
|
112 |
+
"video1": os.path.basename(current_video),
|
113 |
+
"video2": os.path.basename(next_video),
|
114 |
+
"similarity": similarity,
|
115 |
+
"output": str(temp_output),
|
116 |
+
"success": True
|
117 |
+
}
|
118 |
+
|
119 |
+
# 次のループでは結合結果を使用
|
120 |
+
current_video = str(temp_output)
|
121 |
+
|
122 |
+
logger.info(f"結合完了 {i}/{len(video_files)-1}: 類似度 {similarity:.3f}")
|
123 |
+
else:
|
124 |
+
result_info = {
|
125 |
+
"step": i,
|
126 |
+
"video1": os.path.basename(current_video),
|
127 |
+
"video2": os.path.basename(next_video),
|
128 |
+
"error": result_text,
|
129 |
+
"success": False
|
130 |
+
}
|
131 |
+
logger.error(f"結合失敗 {i}/{len(video_files)-1}: {result_text}")
|
132 |
+
|
133 |
+
results.append(result_info)
|
134 |
+
|
135 |
+
# 中間ファイルのクリーンアップ(最後以外)
|
136 |
+
if i > 1 and i < len(video_files) - 1:
|
137 |
+
prev_temp = self.output_dir / f"temp_merge_{i-1}.mp4"
|
138 |
+
if prev_temp.exists():
|
139 |
+
prev_temp.unlink()
|
140 |
+
|
141 |
+
final_output = self.output_dir / output_filename
|
142 |
+
success = final_output.exists()
|
143 |
+
|
144 |
+
if success:
|
145 |
+
logger.info(f"全結合処理完了: {final_output}")
|
146 |
+
logger.info(f"最終ファイルサイズ: {final_output.stat().st_size / (1024*1024):.1f} MB")
|
147 |
+
|
148 |
+
return success, str(final_output), results
|
149 |
+
|
150 |
+
def process_pairwise_merge(self, input_dir: str) -> Tuple[bool, List[str], List[dict]]:
|
151 |
+
"""
|
152 |
+
フォルダ内の動画をペアワイズで結合
|
153 |
+
|
154 |
+
Args:
|
155 |
+
input_dir: 入力ディレクトリ
|
156 |
+
|
157 |
+
Returns:
|
158 |
+
Tuple[成功フラグ, 出力ファイルリスト, 処理結果リスト]
|
159 |
+
"""
|
160 |
+
video_files = self.get_video_files(input_dir)
|
161 |
+
|
162 |
+
if len(video_files) < 2:
|
163 |
+
return False, [], [{"error": "結合には最低2つの動画ファイルが必要です"}]
|
164 |
+
|
165 |
+
results = []
|
166 |
+
output_files = []
|
167 |
+
|
168 |
+
logger.info(f"ペアワイズ結合処理開始: {len(video_files)}個のファイル")
|
169 |
+
|
170 |
+
# ペアごとに処理
|
171 |
+
for i in range(0, len(video_files) - 1, 2):
|
172 |
+
video1 = video_files[i]
|
173 |
+
video2 = video_files[i + 1] if i + 1 < len(video_files) else None
|
174 |
+
|
175 |
+
if video2 is None:
|
176 |
+
# 奇数個の場合、最後のファイルはそのままコピー
|
177 |
+
import shutil
|
178 |
+
output_name = f"single_{os.path.basename(video1)}"
|
179 |
+
output_path = self.output_dir / output_name
|
180 |
+
shutil.copy2(video1, output_path)
|
181 |
+
output_files.append(str(output_path))
|
182 |
+
|
183 |
+
results.append({
|
184 |
+
"pair": i // 2 + 1,
|
185 |
+
"video1": os.path.basename(video1),
|
186 |
+
"video2": None,
|
187 |
+
"action": "copied",
|
188 |
+
"output": str(output_path),
|
189 |
+
"success": True
|
190 |
+
})
|
191 |
+
continue
|
192 |
+
|
193 |
+
logger.info(f"ペア {i//2 + 1}: {os.path.basename(video1)} + {os.path.basename(video2)}")
|
194 |
+
|
195 |
+
# 出力ファイル名
|
196 |
+
output_name = f"merged_pair_{i//2 + 1}_{os.path.basename(video1).split('.')[0]}_{os.path.basename(video2).split('.')[0]}.mp4"
|
197 |
+
output_path = self.output_dir / output_name
|
198 |
+
|
199 |
+
# 結合処理
|
200 |
+
result_text, temp_output, frame1_path, frame2_path, similarity = self.frame_bridge.process_video_bridge(
|
201 |
+
video1, video2
|
202 |
+
)
|
203 |
+
|
204 |
+
if temp_output and os.path.exists(temp_output):
|
205 |
+
# 結果を指定の場所に移動
|
206 |
+
import shutil
|
207 |
+
shutil.move(temp_output, str(output_path))
|
208 |
+
output_files.append(str(output_path))
|
209 |
+
|
210 |
+
result_info = {
|
211 |
+
"pair": i // 2 + 1,
|
212 |
+
"video1": os.path.basename(video1),
|
213 |
+
"video2": os.path.basename(video2),
|
214 |
+
"similarity": similarity,
|
215 |
+
"output": str(output_path),
|
216 |
+
"success": True
|
217 |
+
}
|
218 |
+
|
219 |
+
logger.info(f"ペア結合完了 {i//2 + 1}: 類似度 {similarity:.3f}")
|
220 |
+
else:
|
221 |
+
result_info = {
|
222 |
+
"pair": i // 2 + 1,
|
223 |
+
"video1": os.path.basename(video1),
|
224 |
+
"video2": os.path.basename(video2),
|
225 |
+
"error": result_text,
|
226 |
+
"success": False
|
227 |
+
}
|
228 |
+
logger.error(f"ペア結合失敗 {i//2 + 1}: {result_text}")
|
229 |
+
|
230 |
+
results.append(result_info)
|
231 |
+
|
232 |
+
success = len(output_files) > 0
|
233 |
+
logger.info(f"ペアワイズ結合完了: {len(output_files)}個のファイル出力")
|
234 |
+
|
235 |
+
return success, output_files, results
|
236 |
+
|
237 |
+
def generate_report(self, results: List[dict], output_path: str = None) -> str:
|
238 |
+
"""
|
239 |
+
処理結果のレポートを生成
|
240 |
+
|
241 |
+
Args:
|
242 |
+
results: 処理結果リスト
|
243 |
+
output_path: レポート出力パス
|
244 |
+
|
245 |
+
Returns:
|
246 |
+
レポート文字列
|
247 |
+
"""
|
248 |
+
report_lines = [
|
249 |
+
"🎬 Frame Bridge - バッチ処理レポート",
|
250 |
+
"=" * 60,
|
251 |
+
f"📅 処理日時: {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
252 |
+
f"📊 総処理数: {len(results)}",
|
253 |
+
""
|
254 |
+
]
|
255 |
+
|
256 |
+
success_count = sum(1 for r in results if r.get('success', False))
|
257 |
+
report_lines.extend([
|
258 |
+
f"✅ 成功: {success_count}",
|
259 |
+
f"❌ 失敗: {len(results) - success_count}",
|
260 |
+
""
|
261 |
+
])
|
262 |
+
|
263 |
+
# 詳細結果
|
264 |
+
for i, result in enumerate(results, 1):
|
265 |
+
if result.get('success', False):
|
266 |
+
if 'similarity' in result:
|
267 |
+
quality = self._evaluate_quality(result['similarity'])
|
268 |
+
report_lines.extend([
|
269 |
+
f"📋 処理 {i}: ✅ 成功",
|
270 |
+
f" 📹 動画1: {result.get('video1', 'N/A')}",
|
271 |
+
f" 📹 動画2: {result.get('video2', 'N/A')}",
|
272 |
+
f" 📈 類似度: {result['similarity']:.3f} ({quality})",
|
273 |
+
f" 📁 出力: {os.path.basename(result.get('output', 'N/A'))}",
|
274 |
+
""
|
275 |
+
])
|
276 |
+
else:
|
277 |
+
report_lines.extend([
|
278 |
+
f"📋 処理 {i}: ✅ {result.get('action', '処理完了')}",
|
279 |
+
f" 📹 ファイル: {result.get('video1', 'N/A')}",
|
280 |
+
f" 📁 出力: {os.path.basename(result.get('output', 'N/A'))}",
|
281 |
+
""
|
282 |
+
])
|
283 |
+
else:
|
284 |
+
report_lines.extend([
|
285 |
+
f"📋 処理 {i}: ❌ 失敗",
|
286 |
+
f" 📹 動画1: {result.get('video1', 'N/A')}",
|
287 |
+
f" 📹 動画2: {result.get('video2', 'N/A')}",
|
288 |
+
f" ⚠️ エラー: {result.get('error', '不明なエラー')}",
|
289 |
+
""
|
290 |
+
])
|
291 |
+
|
292 |
+
report_text = "\n".join(report_lines)
|
293 |
+
|
294 |
+
# ファイルに保存
|
295 |
+
if output_path:
|
296 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
297 |
+
f.write(report_text)
|
298 |
+
logger.info(f"レポート保存: {output_path}")
|
299 |
+
|
300 |
+
return report_text
|
301 |
+
|
302 |
+
def _evaluate_quality(self, similarity: float) -> str:
|
303 |
+
"""類似度から品質を評価"""
|
304 |
+
if similarity > 0.8:
|
305 |
+
return "優秀"
|
306 |
+
elif similarity > 0.6:
|
307 |
+
return "良好"
|
308 |
+
elif similarity > 0.4:
|
309 |
+
return "普通"
|
310 |
+
else:
|
311 |
+
return "要確認"
|
src/frame_bridge/config.py
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Frame Bridge - Configuration Module
|
3 |
+
設定管理モジュール
|
4 |
+
"""
|
5 |
+
|
6 |
+
from dataclasses import dataclass
|
7 |
+
from typing import List
|
8 |
+
|
9 |
+
|
10 |
+
@dataclass
|
11 |
+
class VideoProcessorConfig:
|
12 |
+
"""VideoProcessor設定クラス"""
|
13 |
+
similarity_threshold: float = 0.3
|
14 |
+
exclude_edge_frames: bool = True
|
15 |
+
num_frames_video1: int = 30 # 動画1から抽出するフレーム数
|
16 |
+
num_frames_video2: int = 10 # 動画2から抽出するフレーム数
|
17 |
+
comparison_frames: int = 3 # 動画2の比較対象フレーム数
|
18 |
+
|
19 |
+
|
20 |
+
@dataclass
|
21 |
+
class BatchProcessorConfig:
|
22 |
+
"""BatchProcessor設定クラス"""
|
23 |
+
output_dir: str = "output"
|
24 |
+
exclude_edge_frames: bool = True
|
25 |
+
supported_formats: List[str] = None
|
26 |
+
|
27 |
+
def __post_init__(self):
|
28 |
+
if self.supported_formats is None:
|
29 |
+
self.supported_formats = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm']
|
30 |
+
|
31 |
+
|
32 |
+
@dataclass
|
33 |
+
class AppConfig:
|
34 |
+
"""アプリケーション全体設定クラス"""
|
35 |
+
video_processor: VideoProcessorConfig = None
|
36 |
+
batch_processor: BatchProcessorConfig = None
|
37 |
+
|
38 |
+
def __post_init__(self):
|
39 |
+
if self.video_processor is None:
|
40 |
+
self.video_processor = VideoProcessorConfig()
|
41 |
+
if self.batch_processor is None:
|
42 |
+
self.batch_processor = BatchProcessorConfig()
|
43 |
+
|
44 |
+
|
45 |
+
# デフォルト設定
|
46 |
+
DEFAULT_CONFIG = AppConfig()
|
src/frame_bridge/video_processor.py
ADDED
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Frame Bridge - Video Processing Module
|
3 |
+
2つの動画を最適なフレームで結合するための処理モジュール
|
4 |
+
"""
|
5 |
+
|
6 |
+
import cv2
|
7 |
+
import numpy as np
|
8 |
+
from PIL import Image
|
9 |
+
import tempfile
|
10 |
+
import os
|
11 |
+
from skimage.metrics import structural_similarity as ssim
|
12 |
+
from typing import Tuple, List, Optional, Union
|
13 |
+
import logging
|
14 |
+
|
15 |
+
# ログ設定
|
16 |
+
logging.basicConfig(level=logging.INFO)
|
17 |
+
logger = logging.getLogger(__name__)
|
18 |
+
|
19 |
+
|
20 |
+
class VideoProcessor:
|
21 |
+
"""動画処理を行うメインクラス"""
|
22 |
+
|
23 |
+
def __init__(self, similarity_threshold: float = 0.3, exclude_edge_frames: bool = True):
|
24 |
+
"""
|
25 |
+
初期化
|
26 |
+
|
27 |
+
Args:
|
28 |
+
similarity_threshold: フレーム類似度の閾値
|
29 |
+
exclude_edge_frames: 最初と最後のフレームを除外するかどうか
|
30 |
+
"""
|
31 |
+
self.similarity_threshold = similarity_threshold
|
32 |
+
self.exclude_edge_frames = exclude_edge_frames
|
33 |
+
|
34 |
+
def extract_frames(self, video_path: str, num_frames: int = 20) -> Tuple[Optional[List], Optional[str]]:
|
35 |
+
"""
|
36 |
+
動画からフレームを抽出する
|
37 |
+
|
38 |
+
Args:
|
39 |
+
video_path: 動画ファイルのパス
|
40 |
+
num_frames: 抽出するフレーム数
|
41 |
+
|
42 |
+
Returns:
|
43 |
+
Tuple[フレームリスト, エラーメッセージ]
|
44 |
+
"""
|
45 |
+
try:
|
46 |
+
cap = cv2.VideoCapture(video_path)
|
47 |
+
if not cap.isOpened():
|
48 |
+
return None, f"動画ファイルを開けませんでした: {video_path}"
|
49 |
+
|
50 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
51 |
+
if total_frames == 0:
|
52 |
+
return None, "動画にフレームが見つかりませんでした"
|
53 |
+
|
54 |
+
logger.info(f"動画 {video_path}: 総フレーム数 {total_frames}")
|
55 |
+
|
56 |
+
frames = []
|
57 |
+
# 最初と最後のフレームを含む等間隔でフレームを抽出
|
58 |
+
frame_indices = np.linspace(0, total_frames-1, num_frames, dtype=int)
|
59 |
+
|
60 |
+
for frame_idx in frame_indices:
|
61 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
62 |
+
ret, frame = cap.read()
|
63 |
+
if ret:
|
64 |
+
# BGR to RGB変換
|
65 |
+
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
66 |
+
frames.append((frame_idx, frame_rgb))
|
67 |
+
|
68 |
+
cap.release()
|
69 |
+
logger.info(f"フレーム抽出完了: {len(frames)}フレーム")
|
70 |
+
return frames, None
|
71 |
+
|
72 |
+
except Exception as e:
|
73 |
+
logger.error(f"フレーム抽出エラー: {e}")
|
74 |
+
return None, f"フレーム抽出エラー: {str(e)}"
|
75 |
+
|
76 |
+
def calculate_frame_similarity(self, frame1: np.ndarray, frame2: np.ndarray) -> float:
|
77 |
+
"""
|
78 |
+
2つのフレーム間の類似度を計算する
|
79 |
+
|
80 |
+
Args:
|
81 |
+
frame1: 比較フレーム1
|
82 |
+
frame2: 比較フレーム2
|
83 |
+
|
84 |
+
Returns:
|
85 |
+
類似度スコア (0.0-1.0)
|
86 |
+
"""
|
87 |
+
try:
|
88 |
+
# グレースケールに変換
|
89 |
+
gray1 = cv2.cvtColor(frame1, cv2.COLOR_RGB2GRAY)
|
90 |
+
gray2 = cv2.cvtColor(frame2, cv2.COLOR_RGB2GRAY)
|
91 |
+
|
92 |
+
# 同じサイズにリサイズ
|
93 |
+
h, w = min(gray1.shape[0], gray2.shape[0]), min(gray1.shape[1], gray2.shape[1])
|
94 |
+
gray1 = cv2.resize(gray1, (w, h))
|
95 |
+
gray2 = cv2.resize(gray2, (w, h))
|
96 |
+
|
97 |
+
# SSIM(構造的類似性指標)を計算
|
98 |
+
similarity = ssim(gray1, gray2)
|
99 |
+
return max(0.0, similarity) # 負の値を0にクリップ
|
100 |
+
|
101 |
+
except Exception as e:
|
102 |
+
logger.error(f"類似度計算エラー: {e}")
|
103 |
+
return 0.0
|
104 |
+
|
105 |
+
def find_best_connection_frames(self, video1_path: str, video2_path: str) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], float, Optional[str], Tuple[int, int]]:
|
106 |
+
"""
|
107 |
+
2つの動画の最適な接続フレームを見つける
|
108 |
+
動画2の最初のフレームと動画1の全フレームから最も類似したフレームを探索
|
109 |
+
|
110 |
+
Args:
|
111 |
+
video1_path: 動画1のパス
|
112 |
+
video2_path: 動画2のパス
|
113 |
+
|
114 |
+
Returns:
|
115 |
+
Tuple[最適フレーム1, 最適フレーム2, 類似度, エラーメッセージ, フレームインデックス]
|
116 |
+
"""
|
117 |
+
try:
|
118 |
+
# 各動画からフレームを抽出
|
119 |
+
frames1, error1 = self.extract_frames(video1_path, 30) # より多くのフレームを抽出
|
120 |
+
if error1:
|
121 |
+
return None, None, 0.0, error1, (0, 0)
|
122 |
+
|
123 |
+
frames2, error2 = self.extract_frames(video2_path, 10) # 動画2は少なめでOK
|
124 |
+
if error2:
|
125 |
+
return None, None, 0.0, error2, (0, 0)
|
126 |
+
|
127 |
+
# エッジフレーム除外オプションの適用
|
128 |
+
if self.exclude_edge_frames:
|
129 |
+
# 最初と最後のフレームを除外
|
130 |
+
frames1_filtered = frames1[1:-1] if len(frames1) > 2 else frames1
|
131 |
+
frames2_filtered = frames2[1:-1] if len(frames2) > 2 else frames2
|
132 |
+
logger.info(f"エッジフレーム除外: 動画1 {len(frames1)} → {len(frames1_filtered)}フレーム, 動画2 {len(frames2)} → {len(frames2_filtered)}フレーム")
|
133 |
+
else:
|
134 |
+
frames1_filtered = frames1
|
135 |
+
frames2_filtered = frames2
|
136 |
+
logger.info("エッジフレーム除外: 無効")
|
137 |
+
|
138 |
+
# 動画2の最初の数フレームを基準にする(より高精度な探索)
|
139 |
+
video2_start_frames = frames2_filtered[:3] # 動画2の最初の3フレーム(エッジ除外後)
|
140 |
+
|
141 |
+
best_similarity = -1
|
142 |
+
best_frame1 = None
|
143 |
+
best_frame2 = None
|
144 |
+
best_indices = (0, 0)
|
145 |
+
|
146 |
+
logger.info(f"フレーム類似度分析開始: 動画2の最初の{len(video2_start_frames)}フレームと動画1の{len(frames1_filtered)}フレームを比較...")
|
147 |
+
|
148 |
+
# 動画2の各開始フレームについて、動画1の全フレームと比較
|
149 |
+
for j, (idx2, frame2) in enumerate(video2_start_frames):
|
150 |
+
logger.info(f"動画2のフレーム[{idx2}]との比較開始...")
|
151 |
+
|
152 |
+
for i, (idx1, frame1) in enumerate(frames1_filtered):
|
153 |
+
similarity = self.calculate_frame_similarity(frame1, frame2)
|
154 |
+
logger.info(f" 動画1[{idx1}] vs 動画2[{idx2}]: 類似度 {similarity:.3f}")
|
155 |
+
|
156 |
+
if similarity > best_similarity:
|
157 |
+
best_similarity = similarity
|
158 |
+
best_frame1 = frame1
|
159 |
+
best_frame2 = frame2
|
160 |
+
best_indices = (idx1, idx2)
|
161 |
+
logger.info(f" 🌟 新しい最高類似度: {similarity:.3f} (動画1[{idx1}] → 動画2[{idx2}])")
|
162 |
+
|
163 |
+
logger.info(f"最適接続点検出完了: 類似度 {best_similarity:.3f}")
|
164 |
+
logger.info(f"最適結合点: 動画1のフレーム[{best_indices[0]}] → 動画2のフレーム[{best_indices[1]}]")
|
165 |
+
|
166 |
+
return best_frame1, best_frame2, best_similarity, None, best_indices
|
167 |
+
|
168 |
+
except Exception as e:
|
169 |
+
logger.error(f"フレーム比較エラー: {e}")
|
170 |
+
return None, None, 0.0, f"フレーム比較エラー: {str(e)}", (0, 0)
|
171 |
+
|
172 |
+
def create_merged_video(self, video1_path: str, video2_path: str, cut_frame1: int, cut_frame2: int, output_path: str) -> Tuple[bool, Optional[str]]:
|
173 |
+
"""
|
174 |
+
2つの動画を指定されたフレームで結合する
|
175 |
+
|
176 |
+
Args:
|
177 |
+
video1_path: 動画1のパス
|
178 |
+
video2_path: 動画2のパス
|
179 |
+
cut_frame1: 動画1のカットフレーム
|
180 |
+
cut_frame2: 動画2のカットフレーム
|
181 |
+
output_path: 出力パス
|
182 |
+
|
183 |
+
Returns:
|
184 |
+
Tuple[成功フラグ, エラーメッセージ]
|
185 |
+
"""
|
186 |
+
try:
|
187 |
+
# 動画1を読み込み
|
188 |
+
cap1 = cv2.VideoCapture(video1_path)
|
189 |
+
if not cap1.isOpened():
|
190 |
+
return False, "動画1を開けませんでした"
|
191 |
+
|
192 |
+
# 動画2を読み込み
|
193 |
+
cap2 = cv2.VideoCapture(video2_path)
|
194 |
+
if not cap2.isOpened():
|
195 |
+
cap1.release()
|
196 |
+
return False, "動画2を開けませんでした"
|
197 |
+
|
198 |
+
# 動画の情報を取得
|
199 |
+
fps1 = cap1.get(cv2.CAP_PROP_FPS)
|
200 |
+
width1 = int(cap1.get(cv2.CAP_PROP_FRAME_WIDTH))
|
201 |
+
height1 = int(cap1.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
202 |
+
|
203 |
+
logger.info(f"動画1情報: {width1}x{height1}, {fps1}fps")
|
204 |
+
|
205 |
+
# 出力動画の設定(最初の動画の設定を使用)
|
206 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
207 |
+
out = cv2.VideoWriter(output_path, fourcc, fps1, (width1, height1))
|
208 |
+
|
209 |
+
# 動画1の最初からcut_frame1まで
|
210 |
+
frame_count = 0
|
211 |
+
while frame_count <= cut_frame1:
|
212 |
+
ret, frame = cap1.read()
|
213 |
+
if not ret:
|
214 |
+
break
|
215 |
+
out.write(frame)
|
216 |
+
frame_count += 1
|
217 |
+
|
218 |
+
logger.info(f"動画1から {frame_count} フレームを結合")
|
219 |
+
|
220 |
+
# 動画2のcut_frame2から最後まで
|
221 |
+
cap2.set(cv2.CAP_PROP_POS_FRAMES, cut_frame2)
|
222 |
+
frame_count2 = 0
|
223 |
+
while True:
|
224 |
+
ret, frame = cap2.read()
|
225 |
+
if not ret:
|
226 |
+
break
|
227 |
+
# サイズを動画1に合わせる
|
228 |
+
if frame.shape[:2] != (height1, width1):
|
229 |
+
frame = cv2.resize(frame, (width1, height1))
|
230 |
+
out.write(frame)
|
231 |
+
frame_count2 += 1
|
232 |
+
|
233 |
+
logger.info(f"動画2から {frame_count2} フレームを結合")
|
234 |
+
|
235 |
+
# リソースを解放
|
236 |
+
cap1.release()
|
237 |
+
cap2.release()
|
238 |
+
out.release()
|
239 |
+
|
240 |
+
logger.info(f"動画結合完了: {output_path}")
|
241 |
+
return True, None
|
242 |
+
|
243 |
+
except Exception as e:
|
244 |
+
logger.error(f"動画結合エラー: {e}")
|
245 |
+
return False, f"動画結合エラー: {str(e)}"
|
246 |
+
|
247 |
+
def save_frame_as_image(self, frame: np.ndarray, filename: str) -> Optional[str]:
|
248 |
+
"""
|
249 |
+
フレームを画像として保存する
|
250 |
+
|
251 |
+
Args:
|
252 |
+
frame: 保存するフレーム
|
253 |
+
filename: ファイル名
|
254 |
+
|
255 |
+
Returns:
|
256 |
+
保存されたファイルのパス
|
257 |
+
"""
|
258 |
+
try:
|
259 |
+
temp_dir = tempfile.gettempdir()
|
260 |
+
file_path = os.path.join(temp_dir, filename)
|
261 |
+
|
262 |
+
# PIL Imageに変換して保存
|
263 |
+
pil_image = Image.fromarray(frame)
|
264 |
+
pil_image.save(file_path)
|
265 |
+
|
266 |
+
logger.info(f"フレーム画像保存: {file_path}")
|
267 |
+
return file_path
|
268 |
+
|
269 |
+
except Exception as e:
|
270 |
+
logger.error(f"画像保存エラー: {e}")
|
271 |
+
return None
|
272 |
+
|
273 |
+
def analyze_video_details(self, video_path: str) -> str:
|
274 |
+
"""
|
275 |
+
動画の詳細情報を分析する
|
276 |
+
|
277 |
+
Args:
|
278 |
+
video_path: 動画ファイルのパス
|
279 |
+
|
280 |
+
Returns:
|
281 |
+
動画情報の文字列
|
282 |
+
"""
|
283 |
+
try:
|
284 |
+
cap = cv2.VideoCapture(video_path)
|
285 |
+
if not cap.isOpened():
|
286 |
+
return "動画を開けませんでした"
|
287 |
+
|
288 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
289 |
+
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
290 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
291 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
292 |
+
duration = frame_count / fps if fps > 0 else 0
|
293 |
+
|
294 |
+
cap.release()
|
295 |
+
|
296 |
+
return f"""📹 動画情報:
|
297 |
+
• 解像度: {width} x {height}
|
298 |
+
• フレームレート: {fps:.2f} FPS
|
299 |
+
• 総フレーム数: {frame_count}
|
300 |
+
• 再生時間: {duration:.2f} 秒
|
301 |
+
• ファイルサイズ: {os.path.getsize(video_path) / (1024*1024):.1f} MB"""
|
302 |
+
|
303 |
+
except Exception as e:
|
304 |
+
logger.error(f"動画分析エラー: {e}")
|
305 |
+
return f"動画分析エラー: {str(e)}"
|
306 |
+
|
307 |
+
|
308 |
+
class FrameBridge:
|
309 |
+
"""Frame Bridge メインクラス"""
|
310 |
+
|
311 |
+
def __init__(self, exclude_edge_frames: bool = True):
|
312 |
+
"""
|
313 |
+
初期化
|
314 |
+
|
315 |
+
Args:
|
316 |
+
exclude_edge_frames: 最初と最後のフレームを除外するかどうか
|
317 |
+
"""
|
318 |
+
self.processor = VideoProcessor(exclude_edge_frames=exclude_edge_frames)
|
319 |
+
|
320 |
+
def process_video_bridge(self, video1_path: str, video2_path: str) -> Tuple[str, Optional[str], Optional[str], Optional[str], float]:
|
321 |
+
"""
|
322 |
+
2つの動画を分析して最適な結合点を見つけ、結合する
|
323 |
+
|
324 |
+
Args:
|
325 |
+
video1_path: 動画1のパス
|
326 |
+
video2_path: 動画2のパス
|
327 |
+
|
328 |
+
Returns:
|
329 |
+
Tuple[結果テキスト, 結合動画パス, フレーム1パス, フレーム2パス, 類似度]
|
330 |
+
"""
|
331 |
+
if not video1_path or not video2_path:
|
332 |
+
return "2つの動画ファイルが必要です。", None, None, None, 0.0
|
333 |
+
|
334 |
+
if not os.path.exists(video1_path) or not os.path.exists(video2_path):
|
335 |
+
return "指定された動画ファイルが見つかりません。", None, None, None, 0.0
|
336 |
+
|
337 |
+
try:
|
338 |
+
logger.info("動画分析開始...")
|
339 |
+
|
340 |
+
# 最適な接続フレームを見つける
|
341 |
+
frame1, frame2, similarity, error, indices = self.processor.find_best_connection_frames(video1_path, video2_path)
|
342 |
+
|
343 |
+
if error:
|
344 |
+
return f"エラー: {error}", None, None, None, 0.0
|
345 |
+
|
346 |
+
logger.info("最適な接続点を検出しました")
|
347 |
+
|
348 |
+
# フレームを画像として保存
|
349 |
+
frame1_path = self.processor.save_frame_as_image(frame1, "connection_frame1.png")
|
350 |
+
frame2_path = self.processor.save_frame_as_image(frame2, "connection_frame2.png")
|
351 |
+
|
352 |
+
logger.info("動画結合開始...")
|
353 |
+
|
354 |
+
# 結合動画を作成
|
355 |
+
temp_dir = tempfile.gettempdir()
|
356 |
+
output_path = os.path.join(temp_dir, "merged_video.mp4")
|
357 |
+
|
358 |
+
# 最適なフレームで結合
|
359 |
+
success, merge_error = self.processor.create_merged_video(
|
360 |
+
video1_path, video2_path, indices[0], indices[1], output_path
|
361 |
+
)
|
362 |
+
|
363 |
+
if not success:
|
364 |
+
return f"動画結合���ラー: {merge_error}", None, None, None, similarity
|
365 |
+
|
366 |
+
# 品質評価
|
367 |
+
quality = self._evaluate_quality(similarity)
|
368 |
+
|
369 |
+
result_text = f"""🎬 動画結合完了!
|
370 |
+
|
371 |
+
📊 分析結果:
|
372 |
+
• フレーム類似度: {similarity:.3f}
|
373 |
+
• 接続品質: {quality}
|
374 |
+
• 結合フレーム: 動画1[{indices[0]}] → 動画2[{indices[1]}]
|
375 |
+
|
376 |
+
💡 結合情報:
|
377 |
+
• 動画1の最適な終了フレームを検出
|
378 |
+
• 動画2の最適な開始フレームを検出
|
379 |
+
• スムーズな接続を実現
|
380 |
+
|
381 |
+
📁 出力ファイル: {os.path.basename(output_path)}"""
|
382 |
+
|
383 |
+
logger.info("処理完了")
|
384 |
+
return result_text, output_path, frame1_path, frame2_path, similarity
|
385 |
+
|
386 |
+
except Exception as e:
|
387 |
+
logger.error(f"処理エラー: {e}")
|
388 |
+
return f"処理エラー: {str(e)}", None, None, None, 0.0
|
389 |
+
|
390 |
+
def _evaluate_quality(self, similarity: float) -> str:
|
391 |
+
"""類似度から品質を評価"""
|
392 |
+
if similarity > 0.8:
|
393 |
+
return "優秀 🌟"
|
394 |
+
elif similarity > 0.6:
|
395 |
+
return "良好 ✅"
|
396 |
+
elif similarity > 0.4:
|
397 |
+
return "普通 ⚡"
|
398 |
+
else:
|
399 |
+
return "要確認 ⚠️"
|
tests/batch_test.py
ADDED
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Frame Bridge - バッチ処理テスト用スクリプト
|
3 |
+
フォルダ内の動画ファイルを順次結合するテストスクリプト
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
import sys
|
8 |
+
import argparse
|
9 |
+
from pathlib import Path
|
10 |
+
import sys
|
11 |
+
sys.path.append('..')
|
12 |
+
from src.frame_bridge import BatchProcessor
|
13 |
+
|
14 |
+
def main():
|
15 |
+
"""メイン処理"""
|
16 |
+
parser = argparse.ArgumentParser(description="Frame Bridge - バッチ動画結合")
|
17 |
+
parser.add_argument("--input", "-i", default="examples/assets/example/REI/input", help="入力フォルダ (デフォルト: examples/assets/example/REI/input)")
|
18 |
+
parser.add_argument("--output", "-o", default="examples/assets/example/REI/output", help="出力フォルダ (デフォルト: examples/assets/example/REI/output)")
|
19 |
+
parser.add_argument("--exclude-edge", action="store_true", default=True, help="最初と最後のフレームを除外 (デフォルト: True)")
|
20 |
+
parser.add_argument("--include-edge", action="store_true", help="最初と最後のフレームを含める")
|
21 |
+
parser.add_argument("--mode", "-m", choices=["sequential", "pairwise"], default="sequential",
|
22 |
+
help="結合モード: sequential(順次結合) または pairwise(ペア結合)")
|
23 |
+
parser.add_argument("--filename", "-f", default="merged_sequence.mp4", help="出力ファイル名 (sequentialモードのみ)")
|
24 |
+
|
25 |
+
args = parser.parse_args()
|
26 |
+
|
27 |
+
print("🎬 Frame Bridge - バッチ処理テスト")
|
28 |
+
print("=" * 60)
|
29 |
+
print(f"📁 入力フォルダ: {args.input}")
|
30 |
+
print(f"📁 出力フォルダ: {args.output}")
|
31 |
+
print(f"🔄 処理モード: {args.mode}")
|
32 |
+
if args.mode == "sequential":
|
33 |
+
print(f"📄 出力ファイル名: {args.filename}")
|
34 |
+
print()
|
35 |
+
|
36 |
+
# 入力フォルダの存在チェック
|
37 |
+
if not os.path.exists(args.input):
|
38 |
+
print(f"❌ 入力フォルダが見つかりません: {args.input}")
|
39 |
+
return
|
40 |
+
|
41 |
+
# エッジフレーム除外設定
|
42 |
+
exclude_edge_frames = not args.include_edge if args.include_edge else args.exclude_edge
|
43 |
+
|
44 |
+
print(f"🎯 エッジフレーム除外: {'有効' if exclude_edge_frames else '無効'}")
|
45 |
+
print()
|
46 |
+
|
47 |
+
# バッチプロセッサを初期化
|
48 |
+
processor = BatchProcessor(output_dir=args.output, exclude_edge_frames=exclude_edge_frames)
|
49 |
+
|
50 |
+
# 動画ファイルの確認
|
51 |
+
video_files = processor.get_video_files(args.input)
|
52 |
+
if len(video_files) < 2:
|
53 |
+
print("❌ 結合には最低2つの動画ファイルが必要です")
|
54 |
+
return
|
55 |
+
|
56 |
+
print(f"✅ 検出された動画ファイル: {len(video_files)}個")
|
57 |
+
for i, file in enumerate(video_files):
|
58 |
+
print(f" {i+1}. {os.path.basename(file)}")
|
59 |
+
print()
|
60 |
+
|
61 |
+
# 処理モードに応じて実行
|
62 |
+
if args.mode == "sequential":
|
63 |
+
print("🔄 順次結合処理を開始...")
|
64 |
+
success, final_output, results = processor.process_sequential_merge(args.input, args.filename)
|
65 |
+
|
66 |
+
if success:
|
67 |
+
print(f"✅ 順次結合完了!")
|
68 |
+
print(f"📁 最終出力: {final_output}")
|
69 |
+
print(f"📊 ファイルサイズ: {os.path.getsize(final_output) / (1024*1024):.1f} MB")
|
70 |
+
else:
|
71 |
+
print("❌ 順次結合に失敗しました")
|
72 |
+
|
73 |
+
elif args.mode == "pairwise":
|
74 |
+
print("🔄 ペアワイズ結合処理を開始...")
|
75 |
+
success, output_files, results = processor.process_pairwise_merge(args.input)
|
76 |
+
|
77 |
+
if success:
|
78 |
+
print(f"✅ ペアワイズ結合完了!")
|
79 |
+
print(f"📁 出力ファイル数: {len(output_files)}")
|
80 |
+
for i, file in enumerate(output_files):
|
81 |
+
size_mb = os.path.getsize(file) / (1024*1024)
|
82 |
+
print(f" {i+1}. {os.path.basename(file)} ({size_mb:.1f} MB)")
|
83 |
+
else:
|
84 |
+
print("❌ ペアワイズ結合に失敗しました")
|
85 |
+
|
86 |
+
# レポート生成
|
87 |
+
print("\n" + "=" * 60)
|
88 |
+
print("📋 処理レポート:")
|
89 |
+
report_path = Path(args.output) / "batch_report.txt"
|
90 |
+
report = processor.generate_report(results, str(report_path))
|
91 |
+
print(report)
|
92 |
+
|
93 |
+
print("🎉 バッチ処理完了!")
|
94 |
+
|
95 |
+
if __name__ == "__main__":
|
96 |
+
main()
|
tests/test_sample.py
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Frame Bridge - サンプル動画テスト用スクリプト
|
3 |
+
指定されたサンプル動画でFrame Bridgeの機能をテストします
|
4 |
+
"""
|
5 |
+
|
6 |
+
import os
|
7 |
+
import sys
|
8 |
+
import sys
|
9 |
+
sys.path.append('..')
|
10 |
+
from src.frame_bridge import FrameBridge
|
11 |
+
|
12 |
+
def main():
|
13 |
+
"""メイン処理"""
|
14 |
+
print("🎬 Frame Bridge - サンプル動画テスト")
|
15 |
+
print("=" * 50)
|
16 |
+
|
17 |
+
# サンプル動画のパス
|
18 |
+
video1_path = "examples/assets/example/REI/input/REI-001.mp4"
|
19 |
+
video2_path = "examples/assets/example/REI/input/REI-002.mp4"
|
20 |
+
|
21 |
+
# ファイル存在チェック
|
22 |
+
if not os.path.exists(video1_path):
|
23 |
+
print(f"❌ 動画1が見つかりません: {video1_path}")
|
24 |
+
return
|
25 |
+
|
26 |
+
if not os.path.exists(video2_path):
|
27 |
+
print(f"❌ 動画2が見つかりません: {video2_path}")
|
28 |
+
return
|
29 |
+
|
30 |
+
print(f"✅ 動画1: {video1_path}")
|
31 |
+
print(f"✅ 動画2: {video2_path}")
|
32 |
+
print()
|
33 |
+
|
34 |
+
# Frame Bridge インスタンスを作成(エッジフレーム除外有効)
|
35 |
+
frame_bridge = FrameBridge(exclude_edge_frames=True)
|
36 |
+
print(f"🎯 エッジフレーム除外: 有効")
|
37 |
+
|
38 |
+
# 動画情報を表示
|
39 |
+
print("📊 動画1の詳細情報:")
|
40 |
+
print(frame_bridge.processor.analyze_video_details(video1_path))
|
41 |
+
print()
|
42 |
+
|
43 |
+
print("📊 動画2の詳細情報:")
|
44 |
+
print(frame_bridge.processor.analyze_video_details(video2_path))
|
45 |
+
print()
|
46 |
+
|
47 |
+
# フレーム結合処理を実行
|
48 |
+
print("🔄 フレーム結合処理を開始...")
|
49 |
+
result_text, output_path, frame1_path, frame2_path, similarity = frame_bridge.process_video_bridge(
|
50 |
+
video1_path, video2_path
|
51 |
+
)
|
52 |
+
|
53 |
+
print("\n" + "=" * 50)
|
54 |
+
print("📋 処理結果:")
|
55 |
+
print(result_text)
|
56 |
+
|
57 |
+
if output_path and os.path.exists(output_path):
|
58 |
+
print(f"\n✅ 結合動画が作成されました: {output_path}")
|
59 |
+
print(f"📁 ファイルサイズ: {os.path.getsize(output_path) / (1024*1024):.1f} MB")
|
60 |
+
|
61 |
+
if frame1_path and os.path.exists(frame1_path):
|
62 |
+
print(f"🖼️ 接続フレーム1: {frame1_path}")
|
63 |
+
|
64 |
+
if frame2_path and os.path.exists(frame2_path):
|
65 |
+
print(f"🖼️ 接続フレーム2: {frame2_path}")
|
66 |
+
|
67 |
+
print(f"\n📈 最終類似度スコア: {similarity:.3f}")
|
68 |
+
|
69 |
+
print("\n🎉 テスト完了!")
|
70 |
+
|
71 |
+
if __name__ == "__main__":
|
72 |
+
main()
|