MakiAi commited on
Commit
60a1a3b
·
1 Parent(s): 31b3477

✨ 動画フレーム結合のコアモジュール実装

Browse files

- VideoProcessorクラス: SSIM技術による高精度フレーム類似度計算
- FrameBridgeクラス: 2つの動画の最適接続点検出と結合機能
- BatchProcessorクラス: 複数動画の順次結合・ペア結合処理
- 設定管理とパッケージ初期化ファイルを追加

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 "要確認 ⚠️"