diff --git a/README.md b/README.md index 13ed724..ac26cb8 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,23 @@ 街歩き映像に対して自動モザイクを掛けるために開発しました。 -使用:https://github.com/akanametov/yolo-face \ No newline at end of file +使用:https://github.com/akanametov/yolo-face + +## 開発者向け情報 + +### 処理プロセスの単体デバッグ + +顔検出処理をBlenderから独立してテストできます。 + +```bash +# 画像ファイルでテスト +python debug_detector.py --image test.jpg + +# 動画ファイルでテスト +python debug_detector.py --video test.mp4 --frame 0 + +# クイックテスト(簡易版) +./test_quick.sh test.jpg +``` + +詳細は [docs/debugging.md](docs/debugging.md) を参照してください。 \ No newline at end of file diff --git a/debug_detector.py b/debug_detector.py new file mode 100755 index 0000000..f2aa549 --- /dev/null +++ b/debug_detector.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +顔検出処理の単体デバッグスクリプト + +Usage: + # 画像ファイルで検出をテスト + python debug_detector.py --image path/to/image.jpg + + # 動画ファイルで検出をテスト(指定フレームのみ) + python debug_detector.py --video path/to/video.mp4 --frame 100 + + # 動画ファイルで複数フレームをテスト + python debug_detector.py --video path/to/video.mp4 --start 0 --end 10 + + # 結果を保存 + python debug_detector.py --image test.jpg --output result.jpg +""" + +import argparse +import sys +from pathlib import Path +import cv2 +import numpy as np + +# プロジェクトルートをパスに追加 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from server.detector import YOLOFaceDetector + + +def draw_detections(image: np.ndarray, detections, mask=None): + """ + 検出結果を画像に描画 + + Args: + image: 元画像(BGR) + detections: 検出結果のリスト [(x, y, w, h, conf), ...] + mask: マスク画像(オプション) + + Returns: + 描画済み画像 + """ + output = image.copy() + + # マスクをオーバーレイ + if mask is not None: + # マスクを3チャンネルに変換 + mask_colored = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) + # 赤色でオーバーレイ(半透明) + mask_overlay = np.zeros_like(output) + mask_overlay[:, :, 2] = mask # 赤チャンネル + output = cv2.addWeighted(output, 1.0, mask_overlay, 0.3, 0) + + # バウンディングボックスを描画 + for (x, y, w, h, conf) in detections: + # ボックス + cv2.rectangle(output, (x, y), (x + w, y + h), (0, 255, 0), 2) + + # 信頼度テキスト + label = f"{conf:.2f}" + label_size, baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) + y_label = max(y, label_size[1]) + cv2.rectangle( + output, + (x, y_label - label_size[1]), + (x + label_size[0], y_label + baseline), + (0, 255, 0), + -1 + ) + cv2.putText( + output, + label, + (x, y_label), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (0, 0, 0), + 1 + ) + + return output + + +def debug_image(args, detector): + """画像ファイルで検出をデバッグ""" + print(f"画像を読み込み中: {args.image}") + image = cv2.imread(args.image) + + if image is None: + print(f"エラー: 画像を読み込めません: {args.image}") + return + + print(f"画像サイズ: {image.shape[1]}x{image.shape[0]}") + + # 検出実行 + print("顔検出を実行中...") + detections = detector.detect(image) + + print(f"\n検出結果: {len(detections)}個の顔を検出") + for i, (x, y, w, h, conf) in enumerate(detections): + print(f" [{i+1}] x={x}, y={y}, w={w}, h={h}, conf={conf:.3f}") + + # マスク生成 + if len(detections) > 0: + mask = detector.generate_mask( + image.shape, + detections, + mask_scale=args.mask_scale, + feather_radius=args.feather_radius + ) + else: + mask = None + + # 結果を描画 + result = draw_detections(image, detections, mask) + + # 表示または保存 + if args.output: + cv2.imwrite(args.output, result) + print(f"\n結果を保存しました: {args.output}") + + if mask is not None and args.save_mask: + mask_path = args.output.replace('.', '_mask.') + cv2.imwrite(mask_path, mask) + print(f"マスクを保存しました: {mask_path}") + else: + cv2.imshow("Detection Result", result) + if mask is not None: + cv2.imshow("Mask", mask) + print("\nキーを押して終了してください...") + cv2.waitKey(0) + cv2.destroyAllWindows() + + +def debug_video(args, detector): + """動画ファイルで検出をデバッグ""" + print(f"動画を読み込み中: {args.video}") + cap = cv2.VideoCapture(args.video) + + if not cap.isOpened(): + print(f"エラー: 動画を開けません: {args.video}") + return + + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + fps = cap.get(cv2.CAP_PROP_FPS) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + print(f"動画情報: {width}x{height}, {fps:.2f}fps, {total_frames}フレーム") + + # フレーム範囲の決定 + start_frame = args.start if args.start is not None else args.frame + end_frame = args.end if args.end is not None else args.frame + + start_frame = max(0, min(start_frame, total_frames - 1)) + end_frame = max(0, min(end_frame, total_frames - 1)) + + print(f"処理範囲: フレーム {start_frame} - {end_frame}") + + # 出力動画の準備 + out_writer = None + if args.output: + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + out_writer = cv2.VideoWriter(args.output, fourcc, fps, (width, height)) + + # フレーム処理 + for frame_idx in range(start_frame, end_frame + 1): + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) + ret, frame = cap.read() + + if not ret: + print(f"警告: フレーム {frame_idx} を読み込めませんでした") + continue + + # 検出実行 + detections = detector.detect(frame) + + # マスク生成 + if len(detections) > 0: + mask = detector.generate_mask( + frame.shape, + detections, + mask_scale=args.mask_scale, + feather_radius=args.feather_radius + ) + else: + mask = None + + # 結果を描画 + result = draw_detections(frame, detections, mask) + + print(f"フレーム {frame_idx}: {len(detections)}個の顔を検出") + + # 保存または表示 + if out_writer: + out_writer.write(result) + else: + cv2.imshow(f"Frame {frame_idx}", result) + if mask is not None: + cv2.imshow("Mask", mask) + + key = cv2.waitKey(0 if end_frame == start_frame else 30) + if key == ord('q'): + break + + cap.release() + if out_writer: + out_writer.release() + print(f"\n結果を保存しました: {args.output}") + else: + cv2.destroyAllWindows() + + +def main(): + parser = argparse.ArgumentParser( + description="顔検出処理の単体デバッグスクリプト", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + # 入力ソース + input_group = parser.add_mutually_exclusive_group(required=True) + input_group.add_argument("--image", type=str, help="テスト用画像ファイル") + input_group.add_argument("--video", type=str, help="テスト用動画ファイル") + + # 動画用オプション + parser.add_argument("--frame", type=int, default=0, help="処理する動画フレーム番号(デフォルト: 0)") + parser.add_argument("--start", type=int, help="処理開始フレーム(動画のみ)") + parser.add_argument("--end", type=int, help="処理終了フレーム(動画のみ)") + + # 検出パラメータ + parser.add_argument("--conf", type=float, default=0.5, help="信頼度閾値(デフォルト: 0.5)") + parser.add_argument("--iou", type=float, default=0.45, help="NMS IoU閾値(デフォルト: 0.45)") + parser.add_argument("--mask-scale", type=float, default=1.5, help="マスクスケール(デフォルト: 1.5)") + parser.add_argument("--feather-radius", type=int, default=20, help="マスクぼかし半径(デフォルト: 20)") + + # 出力オプション + parser.add_argument("--output", "-o", type=str, help="結果画像/動画の保存先") + parser.add_argument("--save-mask", action="store_true", help="マスク画像も保存する(画像のみ)") + + # モデル + parser.add_argument("--model", type=str, help="カスタムモデルパス") + + args = parser.parse_args() + + # 検出器を初期化 + print("YOLOFaceDetectorを初期化中...") + detector = YOLOFaceDetector( + model_path=args.model, + conf_threshold=args.conf, + iou_threshold=args.iou + ) + + # モデルを事前ロード + print("モデルをロード中...") + _ = detector.model + print("準備完了\n") + + # デバッグ実行 + if args.image: + debug_image(args, detector) + else: + debug_video(args, detector) + + +if __name__ == "__main__": + main() diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 0000000..f9e2a7c --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,151 @@ +# デバッグガイド + +## 処理プロセスの単体デバッグ + +顔検出処理をBlenderアドオンから独立してテストできます。 + +### セットアップ + +```bash +# 仮想環境をアクティベート +source .venv/bin/activate + +# 必要なパッケージがインストールされていることを確認 +pip install ultralytics opencv-python torch +``` + +### 基本的な使い方 + +#### 画像ファイルで検出をテスト + +```bash +# 検出結果を画面に表示 +python debug_detector.py --image path/to/image.jpg + +# 検出結果を保存 +python debug_detector.py --image path/to/image.jpg --output result.jpg + +# マスク画像も保存 +python debug_detector.py --image path/to/image.jpg --output result.jpg --save-mask +``` + +#### 動画ファイルで検出をテスト + +```bash +# 特定のフレームをテスト +python debug_detector.py --video path/to/video.mp4 --frame 100 + +# フレーム範囲をテスト(画面表示) +python debug_detector.py --video path/to/video.mp4 --start 0 --end 10 + +# フレーム範囲を処理して動画保存 +python debug_detector.py --video path/to/video.mp4 --start 0 --end 100 --output result.mp4 +``` + +### パラメータ調整 + +```bash +# 信頼度閾値を調整(デフォルト: 0.5) +python debug_detector.py --image test.jpg --conf 0.3 + +# NMS IoU閾値を調整(デフォルト: 0.45) +python debug_detector.py --image test.jpg --iou 0.5 + +# マスクサイズを調整(デフォルト: 1.5) +python debug_detector.py --image test.jpg --mask-scale 2.0 + +# マスクのぼかし半径を調整(デフォルト: 20) +python debug_detector.py --image test.jpg --feather-radius 30 +``` + +### カスタムモデルの使用 + +```bash +python debug_detector.py --image test.jpg --model path/to/custom_model.pt +``` + +## 推論サーバーの単体起動 + +推論サーバーを単独で起動してテストすることもできます。 + +### サーバー起動 + +```bash +# 仮想環境をアクティベート +source .venv/bin/activate + +# サーバーを起動(ポート8181) +python server/main.py +``` + +### APIテスト + +別のターミナルで: + +```bash +# サーバー状態を確認 +curl http://127.0.0.1:8181/status + +# マスク生成をリクエスト +curl -X POST http://127.0.0.1:8181/generate \ + -H "Content-Type: application/json" \ + -d '{ + "video_path": "/path/to/video.mp4", + "output_dir": "/tmp/masks", + "start_frame": 0, + "end_frame": 10, + "conf_threshold": 0.5, + "iou_threshold": 0.45, + "mask_scale": 1.5 + }' + +# タスクの状態を確認(task_idは上記レスポンスから取得) +curl http://127.0.0.1:8181/tasks/{task_id} + +# タスクをキャンセル +curl -X POST http://127.0.0.1:8181/tasks/{task_id}/cancel +``` + +## トラブルシューティング + +### GPU(ROCm)が認識されない + +```bash +# PyTorchがROCmを認識しているか確認 +python -c "import torch; print(f'CUDA available: {torch.cuda.is_available()}')" + +# ROCm環境変数を確認 +echo $ROCM_PATH +echo $HSA_OVERRIDE_GFX_VERSION +``` + +環境変数が設定されていない場合: + +```bash +source .envrc +# または +eval "$(direnv export bash)" +``` + +### モデルが見つからない + +デフォルトモデルは `models/yolov8n-face-lindevs.pt` に配置する必要があります。 + +```bash +ls -l models/yolov8n-face-lindevs.pt +``` + +### メモリ不足エラー + +大きな動画を処理する場合、メモリ不足になる可能性があります: + +- フレーム範囲を小さく分割して処理 +- `--conf` 閾値を上げて検出数を減らす +- より小さいモデルを使用 + +## デバッグのベストプラクティス + +1. **まず画像でテスト**: 動画よりも画像の方が早く結果を確認できます +2. **パラメータの影響を理解**: `--conf`、`--mask-scale` などを変えて結果を比較 +3. **小さいフレーム範囲から始める**: 動画テストは最初は5-10フレーム程度で +4. **結果を保存して比較**: `--output` オプションで結果を保存し、パラメータごとに比較 diff --git a/flake.nix b/flake.nix index b6d05ce..35faf44 100644 --- a/flake.nix +++ b/flake.nix @@ -64,8 +64,8 @@ # 必要なパッケージのインストール確認とインストール if ! python -c "import torch; print(torch.cuda.is_available())" 2>/dev/null | grep -q "True"; then echo "[Setup] Installing Python dependencies..." - # まずPyTorch ROCm版をインストール(ROCm 6.2用) - pip install --quiet torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2 + # まずPyTorch ROCm版をインストール(ROCm 7.0 nightly - ROCm 7.1.1環境で動作確認済み) + pip install --quiet --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.0 # 次に通常のPyPIから他のパッケージをインストール pip install --quiet \ ultralytics \ diff --git a/run_server.sh b/run_server.sh new file mode 100755 index 0000000..ad7d66f --- /dev/null +++ b/run_server.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# 推論サーバーの単体起動スクリプト + +set -e + +echo "=== Face Detection Inference Server ===" +echo "" + +# 環境変数の読み込み +if [ -f ".env" ]; then + echo "環境変数を読み込み中..." + export $(cat .env | grep -v '^#' | xargs) +else + echo "警告: .env ファイルが見つかりません" +fi + +# 仮想環境の確認とアクティベート +if [ ! -d ".venv" ]; then + echo "エラー: .venv が見つかりません" + echo "仮想環境を作成してください: python -m venv .venv" + exit 1 +fi + +source .venv/bin/activate + +# モデルの確認 +MODEL_PATH="models/yolov8n-face-lindevs.pt" +if [ ! -f "$MODEL_PATH" ]; then + echo "警告: モデルファイルが見つかりません: $MODEL_PATH" + echo "最初のリクエスト時にエラーになる可能性があります" + echo "" +fi + +# GPU情報の表示 +echo "=== GPU情報 ===" +python -c " +import torch +if torch.cuda.is_available(): + print(f'GPU検出: {torch.cuda.get_device_name(0)}') + print(f'ROCm version: {torch.version.hip if hasattr(torch.version, \"hip\") else \"N/A\"}') +else: + print('GPU未検出(CPUモードで動作します)') +" 2>/dev/null || echo "PyTorchが見つかりません" +echo "" + +# サーバー起動 +echo "=== サーバーを起動中 ===" +echo "URL: http://127.0.0.1:8181" +echo "終了するには Ctrl+C を押してください" +echo "" + +python server/main.py diff --git a/test_quick.sh b/test_quick.sh new file mode 100755 index 0000000..01459a6 --- /dev/null +++ b/test_quick.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# クイックテストスクリプト +# 処理プロセスが正常に動作するか確認 + +set -e + +echo "=== 顔検出処理のクイックテスト ===" +echo "" + +# 仮想環境の確認 +if [ ! -d ".venv" ]; then + echo "エラー: .venv が見つかりません" + echo "仮想環境を作成してください: python -m venv .venv" + exit 1 +fi + +# 環境変数の読み込み +if [ -f ".env" ]; then + echo "環境変数を読み込み中..." + export $(cat .env | grep -v '^#' | xargs) +fi + +# 仮想環境をアクティベート +source .venv/bin/activate + +# モデルの確認 +MODEL_PATH="models/yolov8n-face-lindevs.pt" +if [ ! -f "$MODEL_PATH" ]; then + echo "警告: モデルファイルが見つかりません: $MODEL_PATH" + echo "デフォルトモデルをダウンロードしてください" +fi + +# テスト画像の確認 +if [ $# -eq 0 ]; then + echo "使い方: $0 <画像ファイルまたは動画ファイル>" + echo "" + echo "例:" + echo " $0 test.jpg" + echo " $0 test.mp4" + exit 1 +fi + +INPUT_FILE="$1" + +if [ ! -f "$INPUT_FILE" ]; then + echo "エラー: ファイルが見つかりません: $INPUT_FILE" + exit 1 +fi + +# ファイルタイプの判定 +EXT="${INPUT_FILE##*.}" +EXT_LOWER=$(echo "$EXT" | tr '[:upper:]' '[:lower:]') + +echo "入力ファイル: $INPUT_FILE" +echo "" + +# GPU情報の表示 +echo "=== GPU情報 ===" +python -c "import torch; print(f'PyTorch CUDA available: {torch.cuda.is_available()}'); print(f'Device: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else \"CPU\"}') if torch.cuda.is_available() else None" 2>/dev/null || echo "PyTorchが見つかりません" +echo "" + +# テスト実行 +echo "=== 検出テストを開始 ===" +if [[ "$EXT_LOWER" == "mp4" || "$EXT_LOWER" == "avi" || "$EXT_LOWER" == "mov" ]]; then + # 動画の場合は最初の1フレームのみテスト + python debug_detector.py --video "$INPUT_FILE" --frame 0 +else + # 画像の場合 + python debug_detector.py --image "$INPUT_FILE" +fi + +echo "" +echo "=== テスト完了 ===" diff --git a/test_result_frame0.jpg b/test_result_frame0.jpg new file mode 100644 index 0000000..3831c87 Binary files /dev/null and b/test_result_frame0.jpg differ diff --git a/test_result_gpu_frame0.jpg b/test_result_gpu_frame0.jpg new file mode 100644 index 0000000..3831c87 Binary files /dev/null and b/test_result_gpu_frame0.jpg differ diff --git a/test_server_api.py b/test_server_api.py new file mode 100755 index 0000000..b77c52a --- /dev/null +++ b/test_server_api.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +推論サーバーAPIのテストスクリプト + +Usage: + # サーバーの状態確認 + python test_server_api.py --status + + # マスク生成のテスト + python test_server_api.py --video test.mp4 --output /tmp/masks --start 0 --end 10 +""" + +import argparse +import json +import time +import urllib.request +import urllib.error +from pathlib import Path +import sys + + +SERVER_URL = "http://127.0.0.1:8181" + + +def check_status(): + """サーバーの状態を確認""" + try: + with urllib.request.urlopen(f"{SERVER_URL}/status", timeout=2) as response: + data = json.loads(response.read().decode('utf-8')) + print("✓ サーバーは稼働中です") + print(f" Status: {data.get('status')}") + print(f" GPU Available: {data.get('gpu_available')}") + return True + except (urllib.error.URLError, ConnectionRefusedError, TimeoutError) as e: + print("✗ サーバーに接続できません") + print(f" エラー: {e}") + print("\nサーバーを起動してください:") + print(" ./run_server.sh") + return False + + +def submit_task(video_path, output_dir, start_frame, end_frame, conf, iou, mask_scale): + """マスク生成タスクを送信""" + data = { + "video_path": video_path, + "output_dir": output_dir, + "start_frame": start_frame, + "end_frame": end_frame, + "conf_threshold": conf, + "iou_threshold": iou, + "mask_scale": mask_scale, + } + + req = urllib.request.Request( + f"{SERVER_URL}/generate", + data=json.dumps(data).encode('utf-8'), + headers={'Content-Type': 'application/json'}, + method='POST' + ) + + try: + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + return result + except urllib.error.HTTPError as e: + error_msg = e.read().decode('utf-8') + raise RuntimeError(f"サーバーエラー: {error_msg}") + + +def get_task_status(task_id): + """タスクの状態を取得""" + try: + with urllib.request.urlopen(f"{SERVER_URL}/tasks/{task_id}") as response: + return json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError: + return {"status": "unknown"} + + +def cancel_task(task_id): + """タスクをキャンセル""" + try: + req = urllib.request.Request( + f"{SERVER_URL}/tasks/{task_id}/cancel", + method='POST' + ) + with urllib.request.urlopen(req): + pass + return True + except urllib.error.HTTPError: + return False + + +def monitor_task(task_id, poll_interval=0.5): + """タスクの進行状況を監視""" + print(f"\nタスクID: {task_id}") + print("進行状況を監視中...\n") + + last_progress = -1 + + while True: + status = get_task_status(task_id) + state = status.get('status') + progress = status.get('progress', 0) + total = status.get('total', 0) + + # 進行状況の表示 + if progress != last_progress and total > 0: + percentage = (progress / total) * 100 + bar_length = 40 + filled = int(bar_length * progress / total) + bar = '=' * filled + '-' * (bar_length - filled) + print(f"\r[{bar}] {progress}/{total} ({percentage:.1f}%)", end='', flush=True) + last_progress = progress + + # 終了状態のチェック + if state == "completed": + print("\n\n✓ 処理が完了しました") + print(f" 出力先: {status.get('result_path')}") + print(f" メッセージ: {status.get('message')}") + return True + + elif state == "failed": + print("\n\n✗ 処理が失敗しました") + print(f" エラー: {status.get('message')}") + return False + + elif state == "cancelled": + print("\n\n- 処理がキャンセルされました") + return False + + time.sleep(poll_interval) + + +def main(): + parser = argparse.ArgumentParser( + description="推論サーバーAPIのテストスクリプト" + ) + + # 操作モード + mode_group = parser.add_mutually_exclusive_group(required=True) + mode_group.add_argument("--status", action="store_true", help="サーバーの状態を確認") + mode_group.add_argument("--video", type=str, help="処理する動画ファイル") + + # タスクパラメータ + parser.add_argument("--output", type=str, default="/tmp/masks", help="マスク出力先ディレクトリ") + parser.add_argument("--start", type=int, default=0, help="開始フレーム") + parser.add_argument("--end", type=int, default=10, help="終了フレーム") + parser.add_argument("--conf", type=float, default=0.5, help="信頼度閾値") + parser.add_argument("--iou", type=float, default=0.45, help="NMS IoU閾値") + parser.add_argument("--mask-scale", type=float, default=1.5, help="マスクスケール") + + # その他のオプション + parser.add_argument("--no-wait", action="store_true", help="タスク送信後、完了を待たない") + + args = parser.parse_args() + + # 状態確認モード + if args.status: + check_status() + return + + # マスク生成モード + print("=== 推論サーバーAPIテスト ===\n") + + # サーバーの確認 + if not check_status(): + sys.exit(1) + + # 動画ファイルの確認 + if not Path(args.video).exists(): + print(f"\n✗ 動画ファイルが見つかりません: {args.video}") + sys.exit(1) + + video_path = str(Path(args.video).absolute()) + output_dir = str(Path(args.output).absolute()) + + print(f"\n動画: {video_path}") + print(f"出力先: {output_dir}") + print(f"フレーム範囲: {args.start} - {args.end}") + print(f"パラメータ: conf={args.conf}, iou={args.iou}, mask_scale={args.mask_scale}") + + # タスクを送信 + print("\nタスクを送信中...") + try: + result = submit_task( + video_path, + output_dir, + args.start, + args.end, + args.conf, + args.iou, + args.mask_scale + ) + except Exception as e: + print(f"\n✗ タスク送信失敗: {e}") + sys.exit(1) + + task_id = result['id'] + print(f"✓ タスクが送信されました (ID: {task_id})") + + # 完了待機 + if not args.no_wait: + try: + success = monitor_task(task_id) + sys.exit(0 if success else 1) + except KeyboardInterrupt: + print("\n\n中断されました") + print("タスクをキャンセル中...") + if cancel_task(task_id): + print("✓ タスクをキャンセルしました") + sys.exit(130) + else: + print("\nタスクの状態を確認するには:") + print(f" python test_server_api.py --task-status {task_id}") + + +if __name__ == "__main__": + main()