diff --git a/src/batch_equirect2persp_ffmpeg.py b/src/batch_equirect2persp_ffmpeg.py new file mode 100644 index 0000000..d6a0cf2 --- /dev/null +++ b/src/batch_equirect2persp_ffmpeg.py @@ -0,0 +1,276 @@ +""" +ffmpeg を使って、Equirect 360 画像を複数の直線的パースペクティブ画像に変換するスクリプト +設定は config.py から読み込む +""" + +import os +import glob +import shutil +import subprocess +import argparse +import re + +# config から設定を読み込む +import config + +def ensure_ffmpeg(): + if shutil.which("ffmpeg") is None: + raise RuntimeError("ffmpeg が見つかりません。PATH を通すか、インストールしてください。") + +def parse_args(): + p = argparse.ArgumentParser( + description="Equirect 360 images -> multiple rectilinear perspective images using ffmpeg v360." + ) + p.add_argument( + "subdir", + help="output/ を生成先にする。例: test_145frames_0min4sec_20260122_211204" + ) + return p.parse_args() + +def validate_subdir(subdir: str) -> str: + # ifの確認: パス注入対策(../ や \ などを拒否) + if not subdir or subdir.strip() == "": + raise RuntimeError("subdir が空です。処理を中断します。") + if any(sep in subdir for sep in ("/", "\\", ":", "..")): + raise RuntimeError("subdir に禁止文字が含まれています(/, \\, :, ..)。処理を中断します。") + # 文字を絞りたい場合(安全寄り) + if not re.fullmatch(r"[A-Za-z0-9._-]+", subdir): + raise RuntimeError("subdir は英数字と ._- のみ許可です。処理を中断します。") + return subdir + +def build_transforms_14(): + transforms = [ + ( 0, 90, 0), + ( 0, -90, 0), + + (-90, 0, 0), + ( 0, 0, 0), + ( 90, 0, 0), + (180, 0, 0), + + (-135, 45, 0), + ( -45, 45, 0), + ( 45, 45, 0), + ( 135, 45, 0), + + (-135,-45, 0), + ( -45,-45, 0), + ( 45,-45, 0), + ( 135,-45, 0), + ] + return transforms + +def build_transforms_dense(ring_step=None): + """密集度高い変換パターンを構築""" + if ring_step is None: + ring_step = config.RING_STEP_DEG + + def ring(yaw_step, pitch): + yaws = list(range(0, 360, yaw_step)) + return [(yaw if yaw <= 180 else yaw-360, pitch, 0) for yaw in yaws] + + transforms = [] + transforms += [(0, 90, 0), (0, -90, 0)] + transforms += ring(ring_step, 0) + transforms += ring(ring_step, 45) + transforms += ring(ring_step, -45) + return transforms + +def build_v360_options(yaw, pitch, roll): + """v360フィルタオプションを構築""" + opts = [ + "input=e", + "output=rectilinear", + f"h_fov={config.HORIZONTAL_FOV}", + f"v_fov={config.VERTICAL_FOV}", + f"w={config.PERSPECTIVE_WIDTH}", + f"h={config.PERSPECTIVE_HEIGHT}", + f"yaw={yaw}", + f"pitch={pitch}", + f"roll={roll}", + ] + return "v360=" + ":".join(opts) + +def build_output_jobs(base, output_dir, transforms, preset_name): + """出力対象のジョブ一覧を構築する""" + digits = len(str(len(transforms))) + jobs = [] + + for idx, (yaw, pitch, roll) in enumerate(transforms): + out_name = f"{base}_{preset_name}_{idx:0{digits}d}_yaw{yaw:+d}_pit{pitch:+d}_rol{roll:+d}.jpg" + out_path = str(output_dir / out_name) + + if (not config.OVERWRITE) and os.path.exists(out_path): + print(f"skip: {out_name}") + continue + + jobs.append({ + "idx": idx, + "yaw": yaw, + "pitch": pitch, + "roll": roll, + "out_name": out_name, + "out_path": out_path, + }) + + return jobs + +def build_ffmpeg_multi_output_cmd(img_path, jobs): + """1入力画像から複数出力画像を生成する ffmpeg コマンドを構築する""" + cmd = [ + "ffmpeg", + "-y" if config.OVERWRITE else "-n", + "-loglevel", config.FFMPEG_LOGLEVEL, + "-i", img_path, + ] + + if len(jobs) == 1: + job = jobs[0] + cmd += [ + "-vf", build_v360_options(job["yaw"], job["pitch"], job["roll"]), + "-frames:v", "1", + job["out_path"], + ] + return cmd + + split_labels = [f"s{job['idx']}" for job in jobs] + filter_parts = [ + f"[0:v]split={len(jobs)}" + "".join(f"[{label}]" for label in split_labels) + ] + + for label, job in zip(split_labels, jobs): + filter_parts.append( + f"[{label}]{build_v360_options(job['yaw'], job['pitch'], job['roll'])}[o{job['idx']}]" + ) + + cmd += ["-filter_complex", ";".join(filter_parts)] + + for job in jobs: + cmd += [ + "-map", f"[o{job['idx']}]", + "-frames:v", "1", + job["out_path"], + ] + + return cmd + +def run_ffmpeg_for_image(img_path, jobs): + """1画像分の変換をまとめて実行する""" + if not jobs: + return + + cmd = build_ffmpeg_multi_output_cmd(img_path, jobs) + subprocess.run(cmd, check=True) + +def run_ffmpeg_with_fallback(img_path, jobs, base): + """複数出力が失敗した場合は個別実行にフォールバックする""" + try: + run_ffmpeg_for_image(img_path, jobs) + for job in jobs: + print(f" -> {job['out_name']}") + except subprocess.CalledProcessError as e: + print(f" !! 一括変換に失敗: {base} ({e})") + print(" !! 個別変換にフォールバックします") + + for job in jobs: + try: + run_ffmpeg_for_image(img_path, [job]) + print(f" -> {job['out_name']}") + except subprocess.CalledProcessError as single_error: + print(f" !! 失敗: {job['out_name']} ({single_error})") + +def _convert_images(output_dir, input_dir, transforms, preset_name): + """ + 実際の変換処理を実行する共通関数 + """ + images = [] + images += glob.glob(str(input_dir / "*.jpg")) + images += glob.glob(str(input_dir / "*.jpeg")) + images += glob.glob(str(input_dir / "*.png")) + images.sort() + + if not images: + raise RuntimeError(f"入力画像が見つかりません: {input_dir}") + + print(f"[情報] 方向数: {len(transforms)} プリセット: {preset_name}") + + for img_path in images: + base = os.path.splitext(os.path.basename(img_path))[0] + print(f"\n=== {base} ===") + jobs = build_output_jobs(base, output_dir, transforms, preset_name) + + if not jobs: + print(" -> すべて既存ファイルのためスキップ") + continue + + run_ffmpeg_with_fallback(img_path, jobs, base) + + print("\n✅ 変換が完了しました。") + + +def main(): + args = parse_args() + subdir = validate_subdir(args.subdir) + + # 基準を「このスクリプトの場所」に固定(実行場所に依存しない) + output_dir = config.OUTPUT_DIR / subdir + input_dir = output_dir / "temp" / "frames" + + # フォルダ確認 + print("input_dir :", input_dir) + print("output_dir:", output_dir) + if not input_dir.is_dir(): + raise RuntimeError("input_dir が存在しません。処理を中断します。") + + ensure_ffmpeg() + output_dir.mkdir(parents=True, exist_ok=True) + + if config.USE_DENSE_RING: + transforms = build_transforms_dense() + preset_name = f"dense{config.RING_STEP_DEG}" + else: + transforms = build_transforms_14() + preset_name = "rc14" + + _convert_images(output_dir, input_dir, transforms, preset_name) + +def process_frames(subdir: str, use_dense_ring: bool = False): + """ + main.py から呼ばれる用の関数 + + Args: + subdir: output/ 配下のサブディレクトリ名 + use_dense_ring: True なら密集度高い変換、False なら標準14方向 + """ + # 一時的に config の設定を上書き + original_dense = config.USE_DENSE_RING + config.USE_DENSE_RING = use_dense_ring + + try: + subdir = validate_subdir(subdir) + + output_dir = config.OUTPUT_DIR / subdir + input_dir = output_dir / "temp" / "frames" + + if not input_dir.is_dir(): + raise RuntimeError(f"入力フォルダが見つかりません: {input_dir}") + + ensure_ffmpeg() + output_dir.mkdir(parents=True, exist_ok=True) + + if config.USE_DENSE_RING: + transforms = build_transforms_dense() + preset_name = f"dense{config.RING_STEP_DEG}" + else: + transforms = build_transforms_14() + preset_name = "rc14" + + _convert_images(output_dir, input_dir, transforms, preset_name) + + finally: + # 設定を戻す + config.USE_DENSE_RING = original_dense + + +if __name__ == "__main__": + main() diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..0e41e27 --- /dev/null +++ b/src/config.py @@ -0,0 +1,49 @@ +""" +プロジェクト全体の設定ファイル +""" + +from pathlib import Path + +# ==================== パス設定 ==================== +PROJECT_ROOT = Path(__file__).parent.parent + +# フォルダパス +DATA_DIR = PROJECT_ROOT / "data" +DATA_INPUT_DIR = DATA_DIR / "input" +OUTPUT_DIR = DATA_DIR / "output" + +# ==================== フレーム抽出設定 ==================== +# video_frame_extractor.py で使用 +FRAME_PREFIX = "frame" +FRAME_EXTENSION = "jpg" + +# ==================== 顔ぼかし設定 ==================== +# face_blur.py で使用 +BLUR_STRENGTH = 51 # ぼかしの強さ(奇数) + +# ==================== Equirect→パースペクティブ変換設定 ==================== +# batch_equirect2persp_ffmpeg.py で使用 + +# 出力画像の解像度 +PERSPECTIVE_WIDTH = 1024 +PERSPECTIVE_HEIGHT = 1024 + +# FOV (視野角) +HORIZONTAL_FOV = 90 +VERTICAL_FOV = 90 + +# ffmpeg オーバーライト設定 +OVERWRITE = True + +# ========== 変換方法選択 ========== +# False: 14方向(上下左右+斜めなど標準的) +# True: 密集度高く(ring_step_deg ごとにリング状) +USE_DENSE_RING = False +RING_STEP_DEG = 30 # USE_DENSE_RING=True の場合に使用 + +# ==================== 共通設定 ==================== +# ログレベル(OpenCV や ffmpeg へのオプション) +FFMPEG_LOGLEVEL = "error" + +# 一度に処理する画像数など(今後の拡張用) +BATCH_SIZE = None # None = 制限なし diff --git a/src/face_blur.py b/src/face_blur.py new file mode 100644 index 0000000..2208175 --- /dev/null +++ b/src/face_blur.py @@ -0,0 +1,175 @@ +""" +画像内の顔を検出してぼかすスクリプト +顔検出と Gaussian ぼかし処理を実装 +""" +import cv2 +from pathlib import Path +import config + +_FACE_CASCADE = None + + +def get_face_cascade(): + """顔検出器を遅延初期化して再利用する""" + global _FACE_CASCADE + + if _FACE_CASCADE is None: + _FACE_CASCADE = cv2.CascadeClassifier( + cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' + ) + + if _FACE_CASCADE.empty(): + raise RuntimeError("OpenCV の顔検出器を読み込めませんでした") + + return _FACE_CASCADE + + +def blur_faces(image_path, output_path, blur_strength=51): + """ + 画像内の顔を検出してぼかす + + Args: + image_path (str): 入力画像のパス + output_path (str): 出力画像のパス + blur_strength (int): ぼかしの強さ(奇数、大きいほど強い) + + Returns: + int: 検出された顔の数 + """ + # 画像を読み込む + image = cv2.imread(image_path) + if image is None: + print(f"エラー: 画像 '{image_path}' を読み込めませんでした") + return 0 + + # グレースケールに変換 + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # 顔検出器を読み込む(OpenCVのHaar Cascade分類器) + face_cascade = get_face_cascade() + + # 顔を検出 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=5, + minSize=(30, 30) + ) + + # ぼかし強度を奇数に調整 + if blur_strength % 2 == 0: + blur_strength += 1 + + # 検出された各顔にぼかしを適用 + for (x, y, w, h) in faces: + # 顔領域を取得 + face_region = image[y:y+h, x:x+w] + + # Gaussian ぼかしを適用 + blurred_face = cv2.GaussianBlur(face_region, (blur_strength, blur_strength), 0) + + # ぼかした顔を元の画像に戻す + image[y:y+h, x:x+w] = blurred_face + + # 結果を保存 + cv2.imwrite(output_path, image) + + return len(faces) + + +def process_folder(input_folder, blur_strength=51): + """ + フォルダ内の全画像の顔をぼかす + frames フォルダ内の画像を上書きして保存 + + Args: + input_folder (str): 入力フォルダのパス(frames フォルダ) + blur_strength (int): ぼかしの強さ + """ + input_path = Path(input_folder) + + if not input_path.exists(): + print(f"エラー: フォルダ '{input_folder}' が見つかりません") + return + + # サポートする画像拡張子 + image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'} + + # フォルダ内の画像ファイルを取得 + image_files = [ + f for f in input_path.iterdir() + if f.suffix.lower() in image_extensions + ] + + if not image_files: + print(f"エラー: '{input_folder}' に画像ファイルが見つかりません") + return + + # ファイルをソート(順序を保証) + image_files.sort() + get_face_cascade() + + print(f"処理対象: {len(image_files)} 枚の画像") + print(f"出力先: {input_path} (上書き)") + print(f"ぼかし強度: {blur_strength}") + print("\n処理を開始します...\n") + + total_faces = 0 + processed_count = 0 + + for i, image_file in enumerate(image_files, 1): + input_image_path = str(image_file) + # 同じファイルに上書き保存 + output_image_path = str(image_file) + + # 顔検出とぼかし処理 + face_count = blur_faces(input_image_path, output_image_path, blur_strength) + + total_faces += face_count + processed_count += 1 + + print(f"[{i}/{len(image_files)}] {image_file.name}: {face_count}個の顔を検出") + + print(f"\n完了!") + print(f" - 処理した画像: {processed_count} 枚") + print(f" - 検出した顔の総数: {total_faces} 個") + print(f" - 保存先: {input_path}") + + +def main(): + """ + スタンドアロン実行用のメイン関数 + """ + import argparse + import sys + + parser = argparse.ArgumentParser(description='画像内の顔を検出してぼかす') + parser.add_argument('folder', help='output/ 内のフォルダ名(例: test_145frames_0min4sec_20260122_211204)') + parser.add_argument('-b', '--blur', type=int, default=51, + help='ぼかしの強さ(奇数、デフォルト: 51)') + + args = parser.parse_args() + + # 入力パスから"output/"プレフィックスを除去 + folder_name = args.folder.replace('output/', '').replace('output\\', '') + + # config から OUTPUT_DIR を使用 + frame_folder = config.OUTPUT_DIR / folder_name / 'temp' / 'frames' + + if not frame_folder.exists(): + print(f"エラー: '{frame_folder}' が見つかりません") + print(f"正しいフォルダ名を指定してください") + return 1 + + # 顔ぼかし処理を実行 + try: + process_folder(str(frame_folder), args.blur) + return 0 + except Exception as e: + print(f"エラー: {e}") + return 1 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..9f88587 --- /dev/null +++ b/src/main.py @@ -0,0 +1,232 @@ +""" +main.py - フレーム抽出 → (顔ぼかし) → パースペクティブ変換のメインスクリプト + +使用方法: + python main.py extract + python main.py blur + python main.py convert + python main.py pipeline [--blur] [--dense] +""" + +import argparse +import sys +from pathlib import Path + +# config と各機能をインポート +import config +from video_frame_extractor import extract_frames +from face_blur import process_folder as blur_faces_folder +from batch_equirect2persp_ffmpeg import process_frames as convert_equirect + + +def cmd_extract(args): + """ + フレーム抽出コマンド + + data/input/ 配下の動画ファイルを output/ に展開 + """ + print(f"\n{'='*60}") + print(f"🎬 フレーム抽出を開始します") + print(f"{'='*60}") + + video_path = config.DATA_INPUT_DIR / args.video_file + + if not video_path.exists(): + print(f"❌ エラー: '{video_path}' が見つかりません") + return False + + try: + extract_frames( + str(video_path), + str(config.OUTPUT_DIR), + prefix=config.FRAME_PREFIX, + extension=config.FRAME_EXTENSION + ) + print(f"✅ フレーム抽出が完了しました\n") + return True + except Exception as e: + print(f"❌ エラー: {e}\n") + return False + + +def cmd_blur(args): + """ + 顔ぼかしコマンド + + output//temp/frames/ 配下の画像に顔ぼかしを適用 + 結果を output//face_blurred_frames/ に保存 + """ + print(f"\n{'='*60}") + print(f"😊 顔ぼかし処理を開始します") + print(f"{'='*60}") + + folder_name = args.output_folder + # temp/frames フォルダを確認 + frame_folder = config.OUTPUT_DIR / folder_name / "temp" / "frames" + + if not frame_folder.exists(): + print(f"❌ エラー: '{frame_folder}' が見つかりません") + print(f" 先に extract コマンドでフレーム抽出を実行してください") + return False + + try: + blur_faces_folder(str(frame_folder), config.BLUR_STRENGTH) + print(f"✅ 顔ぼかしが完了しました\n") + return True + except Exception as e: + print(f"❌ エラー: {e}\n") + return False + + +def cmd_convert(args): + """ + Equirect→パースペクティブ変換コマンド + + output//temp/frames/ の画像を複数方向に変換 + """ + print(f"\n{'='*60}") + print(f"🔄 Equirect→パースペクティブ変換を開始します") + print(f"{'='*60}") + + folder_name = args.output_folder + + try: + convert_equirect( + folder_name, + use_dense_ring=args.dense + ) + print(f"✅ 変換が完了しました\n") + return True + except Exception as e: + print(f"❌ エラー: {e}\n") + return False + + +def cmd_pipeline(args): + """ + フルパイプライン実行 + extract → (blur) → convert を一気に実行 + """ + print(f"\n{'='*60}") + print(f"🚀 フルパイプラインを開始します") + print(f"{'='*60}\n") + + # Step 1: フレーム抽出 + class ExtractArgs: + def __init__(self, video_file): + self.video_file = video_file + + extract_ok = cmd_extract(ExtractArgs(args.video_file)) + if not extract_ok: + return False + + # フレーム抽出で生成されたフォルダ名を特定 + import os + output_folders = sorted([ + d for d in os.listdir(config.OUTPUT_DIR) + if (config.OUTPUT_DIR / d).is_dir() + ], reverse=True) + + if not output_folders: + print("❌ エラー: 出力フォルダが見つかりません") + return False + + latest_folder = output_folders[0] + print(f"📁 生成されたフォルダ: {latest_folder}\n") + + # Step 2: 顔ぼかし(オプション) + if args.blur: + class BlurArgs: + def __init__(self, folder): + self.output_folder = folder + + blur_ok = cmd_blur(BlurArgs(latest_folder)) + if not blur_ok: + print("⚠️ 顔ぼかしがスキップされました\n") + + # Step 3: パースペクティブ変換 + class ConvertArgs: + def __init__(self, folder, dense): + self.output_folder = folder + self.dense = dense + + convert_ok = cmd_convert(ConvertArgs(latest_folder, args.dense)) + if not convert_ok: + return False + + print(f"{'='*60}") + print(f"🎉 パイプライン完了!") + print(f"{'='*60}\n") + + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Equirect 360動画 → パースペクティブ画像 変換ツール", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +例: + # フレーム抽出のみ + python main.py extract video.mp4 + + # 顔ぼかしのみ + python main.py blur test_145frames_0min4sec_20260122_220022 + + # パースペクティブ変換のみ + python main.py convert test_145frames_0min4sec_20260122_220022 + + # 全処理を実行(顔ぼかしなし) + python main.py pipeline video.mp4 + + # 全処理を実行(顔ぼかしあり、密集度高い変換) + python main.py pipeline video.mp4 --blur --dense + """ + ) + + subparsers = parser.add_subparsers(dest='command', help='実行するコマンド') + + # === extract サブコマンド === + extract_parser = subparsers.add_parser('extract', help='動画からフレームを抽出') + extract_parser.add_argument('video_file', help='data/input/ 内の動画ファイル名 (例: video.mp4)') + + # === blur サブコマンド === + blur_parser = subparsers.add_parser('blur', help='抽出されたフレームの顔をぼかす') + blur_parser.add_argument('output_folder', help='output/ 内のフォルダ名') + + # === convert サブコマンド === + convert_parser = subparsers.add_parser('convert', help='Equirect画像をパースペクティブ変換') + convert_parser.add_argument('output_folder', help='output/ 内のフォルダ名') + convert_parser.add_argument('--dense', action='store_true', help='密集度高い変換を使用') + + # === pipeline サブコマンド === + pipeline_parser = subparsers.add_parser('pipeline', help='フレーム抽出→変換を一気に実行') + pipeline_parser.add_argument('video_file', help='data/input/ 内の動画ファイル名 (例: video.mp4)') + pipeline_parser.add_argument('--blur', action='store_true', help='顔ぼかしを有効にする') + pipeline_parser.add_argument('--dense', action='store_true', help='密集度高い変換を使用') + + # パースしてコマンド実行 + args = parser.parse_args() + + if not args.command: + parser.print_help() + return 1 + + # 各コマンドに対応した処理 + if args.command == 'extract': + success = cmd_extract(args) + elif args.command == 'blur': + success = cmd_blur(args) + elif args.command == 'convert': + success = cmd_convert(args) + elif args.command == 'pipeline': + success = cmd_pipeline(args) + else: + parser.print_help() + return 1 + + return 0 if success else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/video_frame_extractor.py b/src/video_frame_extractor.py new file mode 100644 index 0000000..920c0f8 --- /dev/null +++ b/src/video_frame_extractor.py @@ -0,0 +1,209 @@ +import cv2 +import os +from pathlib import Path +from datetime import datetime +import config + + +def extract_frames(video_path, output_base_folder, prefix="frame", extension="jpg"): + """ + 動画をフレームごとに分割して指定フォルダに保存する + + Args: + video_path (str): 入力動画のパス + output_base_folder (str): 出力先ベースフォルダのパス + prefix (str): 出力ファイル名のプレフィックス (デフォルト: "frame") + extension (str): 出力画像の拡張子 (デフォルト: "jpg") + """ + # 動画ファイルを開く + video = cv2.VideoCapture(video_path) + + if not video.isOpened(): + print(f"エラー: 動画ファイル '{video_path}' を開けませんでした") + return + + # 動画情報を取得 + fps = video.get(cv2.CAP_PROP_FPS) + total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) + duration_sec = total_frames / fps if fps > 0 else 0 + + # 動画ファイル名(拡張子なし)を取得 + video_filename = Path(video_path).stem + + # 時間を分秒形式に変換 + minutes = int(duration_sec // 60) + seconds = int(duration_sec % 60) + + # フォルダ名を生成 + # 形式: "動画名_フレーム数frames_時間min秒sec_タイムスタンプ" + # 例: "video_3000frames_1min40sec_20260122_143025" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + folder_name = f"{video_filename}_{total_frames}frames_{minutes}min{seconds}sec_{timestamp}" + + # 出力フォルダのパスを作成(temp/frames/ 内に保存) + output_folder = os.path.join(output_base_folder, folder_name, "temp", "frames") + Path(output_folder).mkdir(parents=True, exist_ok=True) + + print(f"動画情報:") + print(f" - FPS: {fps}") + print(f" - 総フレーム数: {total_frames}") + print(f" - 解像度: {width}x{height}") + print(f" - 再生時間: {minutes}分{seconds}秒") + print(f" - 出力フォルダ: {folder_name}") + print(f"\nフレーム抽出を開始します...") + + frame_count = 0 + saved_count = 0 + + while True: + # フレームを読み込む + ret, frame = video.read() + + # フレームの読み込みに失敗したら終了 + if not ret: + break + + # ファイル名を生成(ゼロパディング付き) + filename = f"{prefix}_{frame_count:06d}.{extension}" + output_path = os.path.join(output_folder, filename) + + # フレームを保存 + cv2.imwrite(output_path, frame) + saved_count += 1 + + # 進捗表示 + if (frame_count + 1) % 100 == 0: + print(f" 処理中: {frame_count + 1}/{total_frames} フレーム") + + frame_count += 1 + + # リソースを解放 + video.release() + + print(f"\n完了!") + print(f" - 保存されたフレーム数: {saved_count}") + print(f" - 保存先: {output_folder}") + + +def extract_frames_interval(video_path, output_base_folder, interval=1, prefix="frame", extension="jpg"): + """ + 動画から指定間隔でフレームを抽出して保存する + + 注: 現在 main.py からは呼び出されていません + スタンドアロン実行時のみ使用可能です + + Args: + video_path (str): 入力動画のパス + output_base_folder (str): 出力先ベースフォルダのパス + interval (int): フレーム抽出間隔(1なら全フレーム、2なら1フレームおき) + prefix (str): 出力ファイル名のプレフィックス + extension (str): 出力画像の拡張子 + """ + video = cv2.VideoCapture(video_path) + + if not video.isOpened(): + print(f"エラー: 動画ファイル '{video_path}' を開けませんでした") + return + + fps = video.get(cv2.CAP_PROP_FPS) + total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) + duration_sec = total_frames / fps if fps > 0 else 0 + + # 動画ファイル名(拡張子なし)を取得 + video_filename = Path(video_path).stem + + # 時間を分秒形式に変換 + minutes = int(duration_sec // 60) + seconds = int(duration_sec % 60) + + # 抽出予定フレーム数を計算 + expected_frames = (total_frames + interval - 1) // interval + + # フォルダ名を生成 + # 形式: "動画名_フレーム数frames_interval間隔_時間min秒sec_タイムスタンプ" + # 例: "video_300frames_interval10_1min40sec_20260122_143025" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + folder_name = f"{video_filename}_{expected_frames}frames_interval{interval}_{minutes}min{seconds}sec_{timestamp}" + + # 出力フォルダのパスを作成(temp/frames/ 内に保存) + output_folder = os.path.join(output_base_folder, folder_name, "temp", "frames") + Path(output_folder).mkdir(parents=True, exist_ok=True) + + print(f"動画情報:") + print(f" - FPS: {fps}") + print(f" - 総フレーム数: {total_frames}") + print(f" - 再生時間: {minutes}分{seconds}秒") + print(f" - 抽出間隔: {interval}フレームごと") + print(f" - 出力フォルダ: {folder_name}") + print(f"\nフレーム抽出を開始します...") + + frame_count = 0 + saved_count = 0 + + while True: + ret, frame = video.read() + if not ret: + break + + # 指定間隔でフレームを保存 + if frame_count % interval == 0: + filename = f"{prefix}_{saved_count:06d}.{extension}" + output_path = os.path.join(output_folder, filename) + cv2.imwrite(output_path, frame) + saved_count += 1 + + if saved_count % 100 == 0: + print(f" 処理中: {saved_count} フレーム保存済み") + + frame_count += 1 + + video.release() + + print(f"\n完了!") + print(f" - 保存されたフレーム数: {saved_count}") + print(f" - 保存先: {output_folder}") + + +def main(): + """ + スタンドアロン実行用のメイン関数 + """ + import argparse + import sys + + parser = argparse.ArgumentParser(description='動画をフレームごとに分割して保存') + parser.add_argument('filename', help='data/input フォルダ内の動画ファイル名 (例: video.mp4)') + parser.add_argument('interval', type=int, nargs='?', default=1, + help='フレーム抽出間隔 (デフォルト: 1=全フレーム)') + + args = parser.parse_args() + + # config から設定を取得 + video_path = config.DATA_INPUT_DIR / args.filename + output_base_folder = config.OUTPUT_DIR + prefix = config.FRAME_PREFIX + extension = config.FRAME_EXTENSION + + # 動画ファイルの存在確認 + if not video_path.exists(): + print(f"エラー: '{video_path}' が見つかりません") + return 1 + + # フレーム抽出を実行 + try: + if args.interval == 1: + extract_frames(str(video_path), str(output_base_folder), prefix, extension) + else: + extract_frames_interval(str(video_path), str(output_base_folder), args.interval, + prefix, extension) + return 0 + except Exception as e: + print(f"エラー: {e}") + return 1 + + +if __name__ == "__main__": + import sys + sys.exit(main())