Spaces:
Sleeping
Sleeping
✨ 動画フレーム結合のコアモジュール実装
Browse files- VideoProcessorクラス: SSIM技術による高精度フレーム類似度計算
- FrameBridgeクラス: 2つの動画の最適接続点検出と結合機能
- BatchProcessorクラス: 複数動画の順次結合・ペア結合処理
- 設定管理とパッケージ初期化ファイルを追加
- 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
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 "要確認 ⚠️"
|