MakiAi commited on
Commit
a98945a
·
2 Parent(s): 72358ac 11fe9ea

Merge branch 'develop'

Browse files
.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: red
7
- colorTo: indigo
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
  ![frame-bridge](https://github.com/user-attachments/assets/05977e5b-3e63-4ed2-a5f6-74ada8943994)
16
 
17
- # 📚 Wikipedia to Markdown Converter
18
 
19
- *WikipediaページをMarkdown形式に変換するWebアプリケーション*
20
 
21
  [![Python](https://img.shields.io/badge/Python-3.8+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org)
 
22
  [![Gradio](https://img.shields.io/badge/Gradio-5.42+-FF6B6B?style=for-the-badge&logo=gradio&logoColor=white)](https://gradio.app)
23
  [![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](LICENSE)
24
- [![Demo](https://img.shields.io/badge/🚀%20デモサイト-Live-orange?style=for-the-badge)](https://huggingface.co/spaces/MakiAi/wikipedia-to-markdown)
25
 
26
  </div>
27
 
@@ -29,29 +30,29 @@ sdk_version: 5.42.0
29
 
30
  ## 🌟 概要
31
 
32
- **Wikipedia to Markdown Converter** は、Wikipediaの記事を整形されたMarkdownドキュメントに変換するWebアプリケーションです。単体処理と一括処理に対応し、複数のダウンロード形式を提供します。
33
 
34
  ### ✨ **主要機能**
35
 
36
- - 🔄 **単体・一括処理** - 1つまたは複数のWikipediaページを同時変換
37
- - 📊 **詳細分析** - 文字数、成功率、ファイル情報を表示
38
- - 🗜️ **複数形式** - 個別ファイル、結合文書、ZIPダウンロード
39
- - 🌐 **多言語対応** - 全てのWikipedia言語版に対応
40
- - **要使いやすいUI** - 直感的で美しいインターフェース
41
 
42
  ---
43
 
44
  ## 🚀 使い方
45
 
46
- ### **オンラインで試す(推奨)**
47
- **[🚀 デモサイトはこちら](https://huggingface.co/spaces/MakiAi/wikipedia-to-markdown)**
48
 
49
  ### 💻 **ローカルで実行**
50
 
51
  ```bash
52
  # リポジトリをクローン
53
- git clone https://github.com/your-username/wikipedia-to-markdown.git
54
- cd wikipedia-to-markdown
55
 
56
  # 依存関係をインストール
57
  pip install -r requirements.txt
@@ -73,40 +74,34 @@ docker-compose up -d
73
 
74
  ## 📋 操作方法
75
 
76
- ### 🔗 **単体処理**
77
- 1. WikipediaのURLを入力
78
- 2. 「✨ 変換する」ボタンをクリック
79
- 3. 生成されたMarkdownをコピーまたはダウンロード
 
80
 
81
- ### 📚 **一括処理**
82
- 1. 複数のURLを1行に1つずつ入力
83
- 2. 「🚀 一括変換する」ボタンをクリック
84
- 3. 処理結果を確認し、必要な形式でダウンロード
85
-
86
- ### 📊 **処理結果の表示例**
87
  ```
88
- ============================================================
89
- 📊 処理結果サマリー
90
- ============================================================
91
- 🔗 処理対象URL数: 3
92
- 成功: 2
93
- 失敗: 1
94
-
95
- 処理成功: https://ja.wikipedia.org/wiki/Python
96
- 📄 ページタイトル: Python
97
- 📊 文字数: 15,432 文字
98
- 💾 ファイル名: Python.md
99
  ```
100
 
101
  ---
102
 
103
- ## 📦 ダウンロー��形式
104
 
105
- | 形式 | 説明 | 用途 |
106
  |------|------|------|
107
- | **📄 個別ファイル** | 各ページを別々のMarkdownファイル | 個別編集・管理 |
108
- | **📚 結合文書** | 全ページを1つのファイルに結合 | 一括閲覧・印刷 |
109
- | **🗜️ ZIPアーカイブ** | 全ファイルを圧縮してまとめて | 大量ファイルの管理 |
110
 
111
  ---
112
 
@@ -114,25 +109,28 @@ docker-compose up -d
114
 
115
  ### **使用技術**
116
  - **Python 3.8+** - メイン言語
 
 
117
  - **Gradio** - Webインターフェース
118
- - **BeautifulSoup4** - HTML解析
 
119
  - **html2text** - Markdown変換
120
  - **Requests** - HTTP通信
121
 
122
  ### **処理フロー**
123
- 1. **URL検証** - 入力URLの妥当性チェック
124
- 2. **HTML取得** - Wikipediaページの取得
125
- 3. **コンテンツ抽出** - 主要コンテンツの抽出
126
- 4. **クリーンアップ** - 不要部分(脚注、編集リンク等)の削除
127
- 5. **Markdown変換** - 整形されたMarkdownに変換
128
- 6. **ファイル生成** - 各種形式でのファイル出力
129
 
130
  ---
131
 
132
  ## 📁 プロジェクト構成
133
 
134
  ```
135
- wikipedia-to-markdown/
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`の`scrape_wikipedia_to_markdown_final()`関数を編集して、変換処理をカスタマイズできます。
 
 
 
153
 
154
  ---
155
 
@@ -161,7 +162,7 @@ wikipedia-to-markdown/
161
 
162
  ## 🤝 コントリビューション
163
 
164
- バグ報告や機能提案は[GitHub Issues](https://github.com/your-username/wikipedia-to-markdown/issues)でお願いします。
165
 
166
  ---
167
 
@@ -169,6 +170,6 @@ wikipedia-to-markdown/
169
 
170
  **🌟 このプロジェクトが役に立ったらスターをお願いします!**
171
 
172
- *© 2025 Wikipedia to Markdown Converter*
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
  ![frame-bridge](https://github.com/user-attachments/assets/05977e5b-3e63-4ed2-a5f6-74ada8943994)
16
 
17
+ # 🎬 Frame Bridge
18
 
19
+ *2つの動画を最適なフレームで自動結合するAIアプリケーション*
20
 
21
  [![Python](https://img.shields.io/badge/Python-3.8+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org)
22
+ [![OpenCV](https://img.shields.io/badge/OpenCV-4.8+-5C3EE8?style=for-the-badge&logo=opencv&logoColor=white)](https://opencv.org)
23
  [![Gradio](https://img.shields.io/badge/Gradio-5.42+-FF6B6B?style=for-the-badge&logo=gradio&logoColor=white)](https://gradio.app)
24
  [![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](LICENSE)
25
+ [![Demo](https://img.shields.io/badge/🚀%20デモサイト-Live-orange?style=for-the-badge)](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 tempfile
8
  import os
9
- import zipfile
10
- from urllib.parse import urlparse, unquote
11
 
12
- def scrape_wikipedia_to_markdown_final(url: str) -> str:
13
- """
14
- Wikipediaページをスクレイピングし、整形・不要部分削除を行い、
15
- タイトルを付けてMarkdownに変換します。
16
 
17
- 処理フロー:
18
- 1. ページのタイトルをH1見出しとして取得します。
19
- 2. 「登場人物」などの<dt>タグを見出しに変換します。
20
- 3. 生成されたMarkdown文字列から「## 脚注」以降を完全に削除します。
21
- 4. [編集]リンクを削除します。
22
- 5. 最終的にタイトルと本文を結合して返します。
23
-
24
- Args:
25
- url (str): スクレイピング対象のWikipediaページのURL。
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 create_download_file(content, filename):
93
- """ダウンロード用の一時ファイルを作成する関数"""
 
 
 
 
 
 
94
  try:
95
- # 一時ディレクトリにファイルを作成
96
- temp_dir = tempfile.gettempdir()
97
- file_path = os.path.join(temp_dir, filename)
98
 
99
- with open(file_path, 'w', encoding='utf-8') as f:
100
- f.write(content)
 
 
 
 
 
101
 
102
- return file_path
 
 
 
 
 
 
 
 
 
103
  except Exception as e:
104
- print(f"ファイル作成エラー: {e}")
105
- return None
106
 
107
- def create_zip_file(file_paths, zip_filename="wikipedia_export.zip"):
108
- """複数のファイルをZIP形式でまとめる関数"""
 
 
 
109
  try:
110
- temp_dir = tempfile.gettempdir()
111
- zip_path = os.path.join(temp_dir, zip_filename)
112
 
113
- with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
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
- return zip_path
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
- # URLの検証
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
- try:
180
- markdown_content = scrape_wikipedia_to_markdown_final(url)
181
- if markdown_content.startswith("エラー:") or markdown_content.startswith("HTTP"):
182
- results.append(f"❌ 処理失敗: {url}\n エラー: {markdown_content}")
183
- else:
184
- # ページタイトルを抽出
185
- title_match = re.match(r'^# (.+)', markdown_content)
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="Wikipedia to Markdown Converter") as demo:
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);'>📚 Wikipedia to Markdown Converter</h1>
245
- <p style='font-size: 1.2rem; opacity: 0.8;'>WikipediaのURLを入力して、Markdown形式に変換します</p>
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
- url_input = gr.Textbox(
256
- label="🔗 Wikipedia URL",
257
- placeholder="https://ja.wikipedia.org/wiki/...",
258
- value="https://ja.wikipedia.org/wiki/Python"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  )
260
- convert_btn = gr.Button("✨ 変換する", variant="primary")
 
 
261
 
262
  with gr.Column(scale=1):
263
- output_text = gr.Textbox(
264
- label="📝 変換されたMarkdown",
265
- lines=20,
266
- max_lines=50,
267
  show_copy_button=True
268
  )
269
- download_file = gr.File(
270
- label="📥 マークダウンファイルをダウンロード",
271
- visible=False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- urls_input = gr.Textbox(
310
- label="📋 Wikipedia URLリスト(1行に1つずつ)",
311
- placeholder="https://ja.wikipedia.org/wiki/Python\nhttps://ja.wikipedia.org/wiki/JavaScript\nhttps://ja.wikipedia.org/wiki/HTML",
312
- lines=10,
313
- value="https://ja.wikipedia.org/wiki/Python\nhttps://ja.wikipedia.org/wiki/JavaScript"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  )
315
- batch_convert_btn = gr.Button("🚀 一括変換する", variant="primary")
 
316
 
317
  with gr.Column(scale=1):
318
- batch_output_text = gr.Textbox(
319
- label="📝 一括変換結果",
 
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
- individual_downloads = gr.Column(visible=False)
335
- with individual_downloads:
336
- gr.Markdown("### 📥 個別ダウンロード")
337
- individual_file_1 = gr.File(label="", visible=False)
338
- individual_file_2 = gr.File(label="", visible=False)
339
- individual_file_3 = gr.File(label="", visible=False)
340
- individual_file_4 = gr.File(label="", visible=False)
341
- individual_file_5 = gr.File(label="", visible=False)
 
 
342
 
343
- # 一括処理ボタンクリック時の処理
344
- def update_batch_output(urls_text):
345
- content, batch_file_path, individual_files, zip_file_path = process_multiple_urls(urls_text)
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
- batch_convert_btn.click(
379
- fn=update_batch_output,
380
- inputs=urls_input,
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
- gr.Markdown("### 💡 一括処理の使い方")
395
- gr.Markdown("1. テキストエリアに変換したいWikipediaのURLを1行に1つずつ入力します")
396
- gr.Markdown("2. 「🚀 一括変換する」ボタンをクリックします")
397
- gr.Markdown("3. 処理の進行状況が表示され、完了後に結果が表示されます")
398
- gr.Markdown("4. 各URLの処理結果(成功/失敗)が明確に表示されます")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
 
 
400
  gr.Markdown("---")
401
- gr.Markdown("### 🎯 基本的な使用方法")
402
- gr.Markdown("- **単体処理**: 1つのWikipediaページを変換したい場合")
403
- gr.Markdown("- **一括処理**: 複数のWikipediaページを一度に変換したい場合")
404
- gr.Markdown("- 生成されたMarkdownは右側のテキストエリアからコピーできます")
405
- gr.Markdown("- **📥 ダウンロード機能**: 変換が成功すると、マークダウンファイルとして直接ダウンロードできます")
406
- gr.Markdown(" - 単体処理: ページ名に基づいたファイル名で個別ダウンロード")
407
- gr.Markdown(" - 一括処理: 各URLごとの個別ダウンロード + 全体をまとめた一括ダウンロード + **🗜️ ZIPファイル**")
408
- gr.Markdown(" - 個別ダウンロード: 成功した各ページを個別のファイルとしてダウンロード可能(最大5つまで表示)")
409
- gr.Markdown(" - **ZIPダウンロード**: 複数のMarkdownファイルを1つのZIPファイルにまとめてダウンロード")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;'>和モダンなデザインで、使いやすさと美しさを追求しました</p>
 
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
- requests>=2.31.0
2
- beautifulsoup4>=4.12.0
3
- html2text>=2020.1.16
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()