From 08d3f070448a7c7e8b88df6e83f35d534120a7ef Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:29:42 +0900 Subject: [PATCH 01/57] first commit From 745653e4b80ffe4333e51d53127a904cd20d1e72 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:37:25 +0900 Subject: [PATCH 02/57] =?UTF-8?q?[feat]=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=A4=9C=E7=9F=A5=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=81=AE?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E6=A7=8B?= =?UTF-8?q?=E9=80=A0=E3=82=92=E6=A7=8B=E7=AF=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - line_setup/: ライン座標設定ツール用ディレクトリ - detection/: 車両検知・ライン交差判定用ディレクトリ - result_output/: 結果出力(動画・ログ)用ディレクトリ - data/: 入出力データ用ディレクトリ - requirements.txt: 依存パッケージ定義 --- raspi/line_detection/data/inputs/.gitkeep | 0 .../line_detection/data/outputs/logs/.gitkeep | 0 .../data/outputs/videos/.gitkeep | 0 raspi/line_detection/detection/__init__.py | 0 raspi/line_detection/requirements.txt | 18 ++++++++++++++++++ raspi/line_detection/result_output/__init__.py | 0 6 files changed, 18 insertions(+) create mode 100644 raspi/line_detection/data/inputs/.gitkeep create mode 100644 raspi/line_detection/data/outputs/logs/.gitkeep create mode 100644 raspi/line_detection/data/outputs/videos/.gitkeep create mode 100644 raspi/line_detection/detection/__init__.py create mode 100644 raspi/line_detection/requirements.txt create mode 100644 raspi/line_detection/result_output/__init__.py diff --git a/raspi/line_detection/data/inputs/.gitkeep b/raspi/line_detection/data/inputs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/raspi/line_detection/data/outputs/logs/.gitkeep b/raspi/line_detection/data/outputs/logs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/raspi/line_detection/data/outputs/videos/.gitkeep b/raspi/line_detection/data/outputs/videos/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/raspi/line_detection/detection/__init__.py b/raspi/line_detection/detection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/raspi/line_detection/requirements.txt b/raspi/line_detection/requirements.txt new file mode 100644 index 00000000..d3045759 --- /dev/null +++ b/raspi/line_detection/requirements.txt @@ -0,0 +1,18 @@ +# YOLOv8とトラッキング +ultralytics==8.2.52 + +# 動画処理 +opencv-python==4.8.1.78 + +# 設定管理 +python-dotenv==1.0.0 + +# 数値演算 +numpy==1.24.4 + +# 深層学習バックエンド +torch==2.2.2 +torchvision==0.17.2 + +# データ処理 +pandas==2.2.1 diff --git a/raspi/line_detection/result_output/__init__.py b/raspi/line_detection/result_output/__init__.py new file mode 100644 index 00000000..e69de29b From 5931d850bfd322434afd7551006eaf45845c47d7 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:39:42 +0900 Subject: [PATCH 03/57] =?UTF-8?q?[feat]=20=E8=A8=AD=E5=AE=9A=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85(.env=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detection/config.py: .envから設定を読み込むConfigクラス - .env.template: 設定ファイルのテンプレート - Line1/Line2のライン座標、駐車場基準点、検知パラメータを管理 --- raspi/line_detection/.env.template | 35 +++++ raspi/line_detection/detection/config.py | 174 +++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 raspi/line_detection/.env.template create mode 100644 raspi/line_detection/detection/config.py diff --git a/raspi/line_detection/.env.template b/raspi/line_detection/.env.template new file mode 100644 index 00000000..e77a2ad5 --- /dev/null +++ b/raspi/line_detection/.env.template @@ -0,0 +1,35 @@ +# プロジェクトパス +HOME_DIR=/Users/ycn/Workspace/NUTMEG/tracking-parking + +# YOLOモデル +MODEL_PATH=${HOME_DIR}/yolo_fine_tuning/runs/detect/train14/weights/best.pt +CONFIDENCE_THRESHOLD=0.3 + +# Line1設定(入口側ライン) - setup/setup_lines.pyで自動設定 +LINE1_START_X=300 +LINE1_START_Y=400 +LINE1_END_X=800 +LINE1_END_Y=400 + +# Line2設定(駐車場側ライン) - setup/setup_lines.pyで自動設定 +LINE2_START_X=300 +LINE2_START_Y=200 +LINE2_END_X=800 +LINE2_END_Y=200 + +# 駐車場基準点(駐車場側を定義) - setup/setup_lines.pyで自動設定 +PARKING_REF_X=550 +PARKING_REF_Y=100 + +# 検知パラメータ +MARGIN=1000.0 +MAX_FRAME_GAP=90 # 3秒@30fps +CLEANUP_THRESHOLD=150 # 5秒@30fps + +# 処理方式: hybrid固定 +METHOD=hybrid + +# 出力設定 +SAVE_VIDEO=true +SAVE_LOGS=true +SHOW_DISPLAY=false diff --git a/raspi/line_detection/detection/config.py b/raspi/line_detection/detection/config.py new file mode 100644 index 00000000..5640fca5 --- /dev/null +++ b/raspi/line_detection/detection/config.py @@ -0,0 +1,174 @@ +""" +設定管理モジュール +.envファイルから設定を読み込み、Configオブジェクトとして提供する +""" + +import os +from dataclasses import dataclass +from typing import Tuple +from dotenv import load_dotenv + + +@dataclass +class Line: + """ライン情報を保持するデータクラス""" + start: Tuple[float, float] # (x, y) + end: Tuple[float, float] # (x, y) + + def __post_init__(self): + """座標を float に変換""" + self.start = (float(self.start[0]), float(self.start[1])) + self.end = (float(self.end[0]), float(self.end[1])) + + +@dataclass +class Config: + """2ライン検知システムの設定を保持するクラス""" + + # プロジェクトパス + home_dir: str + + # YOLOモデル設定 + model_path: str + confidence_threshold: float + + # ライン設定 + line1: Line # 入口側ライン(主判定) + line2: Line # 駐車場側ライン(補助) + parking_ref_point: Tuple[float, float] # 駐車場基準点 + + # 検知パラメータ + margin: float # ライン交差判定のマージン + max_frame_gap: int # Line1とLine2の最大フレーム差 + cleanup_threshold: int # 古い追跡をクリーンアップするフレーム数 + + # 処理方式 + method: str # "hybrid" 固定 + + # 出力設定 + save_video: bool + save_logs: bool + show_display: bool + + @classmethod + def from_env(cls, env_path: str = None): + """ + .envファイルから設定を読み込む + + Args: + env_path: .envファイルのパス(Noneの場合はカレントディレクトリから検索) + + Returns: + Config: 設定オブジェクト + """ + # .envファイルを読み込み + if env_path: + load_dotenv(env_path) + else: + load_dotenv() + + # プロジェクトパス + home_dir = os.getenv("HOME_DIR") + if not home_dir: + raise ValueError("HOME_DIR が .env に設定されていません") + + # YOLOモデル設定 + model_path = os.getenv("MODEL_PATH") + if not model_path: + raise ValueError("MODEL_PATH が .env に設定されていません") + + confidence_threshold = float(os.getenv("CONFIDENCE_THRESHOLD", "0.3")) + + # Line1設定(入口側) + line1 = Line( + start=( + int(os.getenv("LINE1_START_X", "0")), + int(os.getenv("LINE1_START_Y", "0")) + ), + end=( + int(os.getenv("LINE1_END_X", "0")), + int(os.getenv("LINE1_END_Y", "0")) + ) + ) + + # Line2設定(駐車場側) + line2 = Line( + start=( + int(os.getenv("LINE2_START_X", "0")), + int(os.getenv("LINE2_START_Y", "0")) + ), + end=( + int(os.getenv("LINE2_END_X", "0")), + int(os.getenv("LINE2_END_Y", "0")) + ) + ) + + # 駐車場基準点 + parking_ref_point = ( + float(os.getenv("PARKING_REF_X", "0")), + float(os.getenv("PARKING_REF_Y", "0")) + ) + + # 検知パラメータ + margin = float(os.getenv("MARGIN", "1000.0")) + max_frame_gap = int(os.getenv("MAX_FRAME_GAP", "90")) + cleanup_threshold = int(os.getenv("CLEANUP_THRESHOLD", "150")) + + # 処理方式 + method = os.getenv("METHOD", "hybrid") + + # 出力設定 + save_video = os.getenv("SAVE_VIDEO", "true").lower() == "true" + save_logs = os.getenv("SAVE_LOGS", "true").lower() == "true" + show_display = os.getenv("SHOW_DISPLAY", "false").lower() == "true" + + return cls( + home_dir=home_dir, + model_path=model_path, + confidence_threshold=confidence_threshold, + line1=line1, + line2=line2, + parking_ref_point=parking_ref_point, + margin=margin, + max_frame_gap=max_frame_gap, + cleanup_threshold=cleanup_threshold, + method=method, + save_video=save_video, + save_logs=save_logs, + show_display=show_display + ) + + def validate(self): + """設定値の妥当性をチェック""" + errors = [] + + # モデルパスの存在確認 + if not os.path.exists(self.model_path): + errors.append(f"モデルファイルが見つかりません: {self.model_path}") + + # ライン座標のチェック + if self.line1.start == self.line1.end: + errors.append("Line1の始点と終点が同じです") + + if self.line2.start == self.line2.end: + errors.append("Line2の始点と終点が同じです") + + # パラメータの範囲チェック + if self.confidence_threshold < 0 or self.confidence_threshold > 1: + errors.append(f"confidence_thresholdは0-1の範囲で設定してください: {self.confidence_threshold}") + + if self.margin < 0: + errors.append(f"marginは正の値である必要があります: {self.margin}") + + if self.max_frame_gap < 0: + errors.append(f"max_frame_gapは正の値である必要があります: {self.max_frame_gap}") + + if self.cleanup_threshold < 0: + errors.append(f"cleanup_thresholdは正の値である必要があります: {self.cleanup_threshold}") + + # 処理方式のチェック + if self.method != "hybrid": + errors.append(f"methodは'hybrid'である必要があります: {self.method}") + + if errors: + raise ValueError("設定エラー:\n" + "\n".join(f" - {e}" for e in errors)) From a8e5fbbc6f898ff0c9a96721380d24e5db843315 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:40:50 +0900 Subject: [PATCH 04/57] =?UTF-8?q?[feat]=20=E5=A4=96=E7=A9=8D=E6=B3=95?= =?UTF-8?q?=E3=81=AB=E3=82=88=E3=82=8B=E3=83=A9=E3=82=A4=E3=83=B3=E4=BA=A4?= =?UTF-8?q?=E5=B7=AE=E6=A4=9C=E7=9F=A5=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detection/line_crossing.py: LineCrossingDetectorクラス - 外積を使用したライン交差判定 - 車両代表点(bbox底面中央)の取得 - IN/OUT方向の判定 - マージン処理による微小な揺れの無視 --- .../line_detection/detection/line_crossing.py | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 raspi/line_detection/detection/line_crossing.py diff --git a/raspi/line_detection/detection/line_crossing.py b/raspi/line_detection/detection/line_crossing.py new file mode 100644 index 00000000..555b1e57 --- /dev/null +++ b/raspi/line_detection/detection/line_crossing.py @@ -0,0 +1,189 @@ +""" +ライン交差検知モジュール +外積法を使用してライン交差を判定する +""" + +from typing import Tuple, Optional, List +from detection.config import Line + + +def side_of_line(point: Tuple[float, float], + line_start: Tuple[float, float], + line_end: Tuple[float, float]) -> float: + """ + 外積を使用してポイントがラインのどちら側にあるか判定 + + Args: + point: 判定するポイント (x, y) + line_start: ラインの始点 (x, y) + line_end: ラインの終点 (x, y) + + Returns: + float: 外積の値 + > 0: ポイントはライン の片側 + < 0: ポイントはラインの反対側 + = 0: ポイントはライン上 + """ + # 外積: (B-A) × (P-A) + # = (x2-x1) * (py-y1) - (y2-y1) * (px-x1) + return (line_end[0] - line_start[0]) * (point[1] - line_start[1]) - \ + (line_end[1] - line_start[1]) * (point[0] - line_start[0]) + + +def get_vehicle_point(bbox: List[float]) -> Tuple[float, float]: + """ + bboxから車両代表点(底面中央)を取得 + + Args: + bbox: [x1, y1, x2, y2] の形式のバウンディングボックス + + Returns: + Tuple[float, float]: 車両代表点 (x, y) + 底面中央: ((x1 + x2) / 2, y2) + """ + x1, y1, x2, y2 = bbox + return ((x1 + x2) / 2.0, y2) + + +class LineCrossingDetector: + """ライン交差検知クラス""" + + def __init__(self, + line1: Line, + line2: Line, + parking_ref_point: Tuple[float, float], + margin: float = 1000.0): + """ + Args: + line1: Line1(入口側ライン) + line2: Line2(駐車場側ライン) + parking_ref_point: 駐車場基準点 + margin: ライン交差判定のマージン + """ + self.line1 = line1 + self.line2 = line2 + self.parking_ref_point = parking_ref_point + self.margin = margin + + # 駐車場側の符号を事前計算 + self.parking_side_line1 = side_of_line( + parking_ref_point, + line1.start, + line1.end + ) + self.parking_side_line2 = side_of_line( + parking_ref_point, + line2.start, + line2.end + ) + + def detect_line1_crossing(self, + prev_point: Tuple[float, float], + curr_point: Tuple[float, float]) -> Optional[str]: + """ + Line1(入口側)の交差を検知 + + Args: + prev_point: 前フレームの車両位置 + curr_point: 現フレームの車両位置 + + Returns: + Optional[str]: + "IN": 入庫方向に交差 + "OUT": 出庫方向に交差 + None: 交差なし + """ + return self._detect_crossing( + prev_point, + curr_point, + self.line1, + self.parking_side_line1 + ) + + def detect_line2_crossing(self, + prev_point: Tuple[float, float], + curr_point: Tuple[float, float]) -> bool: + """ + Line2(駐車場側)の交差を検知 + + Args: + prev_point: 前フレームの車両位置 + curr_point: 現フレームの車両位置 + + Returns: + bool: 交差した場合True、そうでない場合False + """ + result = self._detect_crossing( + prev_point, + curr_point, + self.line2, + self.parking_side_line2 + ) + return result is not None + + def _detect_crossing(self, + prev_point: Tuple[float, float], + curr_point: Tuple[float, float], + line: Line, + parking_side: float) -> Optional[str]: + """ + ライン交差を検知(内部実装) + + Args: + prev_point: 前フレームの車両位置 + curr_point: 現フレームの車両位置 + line: 判定するライン + parking_side: 駐車場側の符号 + + Returns: + Optional[str]: + "IN": 入庫方向に交差 + "OUT": 出庫方向に交差 + None: 交差なし + """ + # 前フレームと現フレームの側を計算 + prev_side = side_of_line(prev_point, line.start, line.end) + curr_side = side_of_line(curr_point, line.start, line.end) + + # マージン処理: 微小な揺れを無視 + if abs(prev_side) < self.margin or abs(curr_side) < self.margin: + return None + + # 符号が変化 = ライン交差 + if prev_side * curr_side < 0: + # 駐車場基準点でIN/OUT判定 + # curr_sideとparking_sideの符号が同じ = 駐車場側に移動 = IN + # curr_sideとparking_sideの符号が異なる = 道路側に移動 = OUT + if curr_side * parking_side > 0: + return "IN" + else: + return "OUT" + + return None + + def is_point_near_line(self, + point: Tuple[float, float], + line: Line, + threshold: float = 50.0) -> bool: + """ + ポイントがラインの近くにあるかチェック + + Args: + point: チェックするポイント + line: ライン + threshold: 距離の閾値(ピクセル) + + Returns: + bool: ラインの近くにある場合True + """ + # ラインまでの距離を計算(外積を使った垂線距離) + side = abs(side_of_line(point, line.start, line.end)) + + # ラインの長さ + line_length = ((line.end[0] - line.start[0]) ** 2 + + (line.end[1] - line.start[1]) ** 2) ** 0.5 + + # 垂線距離 = |外積| / ラインの長さ + distance = side / line_length if line_length > 0 else float('inf') + + return distance < threshold From 4f4550875339bc9cc63f0f83ecb3b505588af2b2 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:42:05 +0900 Subject: [PATCH 05/57] =?UTF-8?q?[feat]=20=E8=BB=8A=E4=B8=A1=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=83=E3=82=AD=E3=83=B3=E3=82=B0=E3=81=A8=E7=8A=B6?= =?UTF-8?q?=E6=85=8B=E7=AE=A1=E7=90=86=E3=82=92=E5=AE=9F=E8=A3=85(?= =?UTF-8?q?=E3=83=8F=E3=82=A4=E3=83=96=E3=83=AA=E3=83=83=E3=83=89=E6=96=B9?= =?UTF-8?q?=E5=BC=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detection/tracker.py: VehicleTrackerクラスとVehicleStateデータクラス - track_id毎の状態管理(Line1/Line2交差情報) - ハイブリッド方式のイベント判定ロジック - 信頼度計算(high/normal) - 古い追跡のクリーンアップ機能 - 統計情報の管理(入庫/出庫/駐車台数) --- raspi/line_detection/detection/tracker.py | 259 ++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 raspi/line_detection/detection/tracker.py diff --git a/raspi/line_detection/detection/tracker.py b/raspi/line_detection/detection/tracker.py new file mode 100644 index 00000000..c99b2f40 --- /dev/null +++ b/raspi/line_detection/detection/tracker.py @@ -0,0 +1,259 @@ +""" +車両トラッキングと状態管理モジュール +ハイブリッド方式による入出庫判定を実装 +""" + +from dataclasses import dataclass, field +from typing import Dict, Optional, Tuple, List +import time + + +@dataclass +class VehicleState: + """車両の状態を保持するデータクラス""" + + track_id: int + prev_point: Optional[Tuple[float, float]] = None + curr_point: Optional[Tuple[float, float]] = None + + # Line1状態(主判定ライン) + line1_direction: Optional[str] = None # "IN" or "OUT" + line1_frame: Optional[int] = None + line1_timestamp: Optional[float] = None + + # Line2状態(補助ライン) + line2_passed: bool = False + line2_frame: Optional[int] = None + line2_timestamp: Optional[float] = None + + # イベント追跡 + passed_order: List[str] = field(default_factory=list) # ["line1", "line2"] など + counted: bool = False + confidence: Optional[str] = None # "high" or "normal" + + # 更新追跡 + last_update_frame: int = 0 + + def update_position(self, point: Tuple[float, float], frame_id: int): + """ + 車両位置を更新 + + Args: + point: 新しい車両位置 + frame_id: フレーム番号 + """ + self.prev_point = self.curr_point + self.curr_point = point + self.last_update_frame = frame_id + + def record_line1_crossing(self, direction: str, frame_id: int): + """ + Line1交差を記録 + + Args: + direction: "IN" or "OUT" + frame_id: フレーム番号 + """ + self.line1_direction = direction + self.line1_frame = frame_id + self.line1_timestamp = time.time() + self.passed_order.append("line1") + + def record_line2_crossing(self, frame_id: int): + """ + Line2交差を記録 + + Args: + frame_id: フレーム番号 + """ + self.line2_passed = True + self.line2_frame = frame_id + self.line2_timestamp = time.time() + self.passed_order.append("line2") + + def calculate_confidence(self, max_frame_gap: int) -> Optional[str]: + """ + 信頼度を計算(ハイブリッド方式) + + Args: + max_frame_gap: Line1とLine2の最大フレーム差 + + Returns: + Optional[str]: "high" or "normal" or None + """ + # Line1を交差していない場合は信頼度なし + if not self.line1_frame: + return None + + # Line2を交差していない場合はnormal + if not self.line2_passed: + return "normal" + + # フレーム差をチェック + frame_diff = abs(self.line1_frame - self.line2_frame) + if frame_diff > max_frame_gap: + return "normal" + + # 通過順序をチェック + if self.line1_direction == "IN": + # 入庫の場合: line1 → line2 が正しい順序 + expected_order = ["line1", "line2"] + else: # OUT + # 出庫の場合: line2 → line1 が正しい順序 + expected_order = ["line2", "line1"] + + if self.passed_order == expected_order: + return "high" + else: + return "normal" + + +class VehicleTracker: + """車両トラッキングクラス""" + + def __init__(self, max_frame_gap: int = 90, cleanup_threshold: int = 150): + """ + Args: + max_frame_gap: Line1とLine2の最大フレーム差 + cleanup_threshold: 古い追跡をクリーンアップするフレーム数 + """ + self.states: Dict[int, VehicleState] = {} + self.max_frame_gap = max_frame_gap + self.cleanup_threshold = cleanup_threshold + + # 統計情報 + self.total_in = 0 + self.total_out = 0 + self.high_confidence_count = 0 + self.normal_confidence_count = 0 + + def update(self, track_id: int, point: Tuple[float, float], frame_id: int) -> VehicleState: + """ + 車両状態を更新または作成 + + Args: + track_id: トラッキングID + point: 車両位置 + frame_id: フレーム番号 + + Returns: + VehicleState: 更新された車両状態 + """ + if track_id not in self.states: + # 新しい車両を追加 + self.states[track_id] = VehicleState(track_id=track_id) + + state = self.states[track_id] + state.update_position(point, frame_id) + + return state + + def get_state(self, track_id: int) -> Optional[VehicleState]: + """ + 車両状態を取得 + + Args: + track_id: トラッキングID + + Returns: + Optional[VehicleState]: 車両状態(存在しない場合はNone) + """ + return self.states.get(track_id) + + def should_count_event(self, state: VehicleState) -> bool: + """ + イベントをカウントすべきかハイブリッド方式で判定 + + Args: + state: 車両状態 + + Returns: + bool: カウントすべき場合True + """ + # すでにカウント済み + if state.counted: + return False + + # Line1を交差していない + if not state.line1_direction: + return False + + # ハイブリッド方式: Line1交差でカウント + # (Line2は信頼度にのみ影響) + return True + + def mark_as_counted(self, track_id: int) -> Optional[str]: + """ + イベントをカウント済みとしてマーク + + Args: + track_id: トラッキングID + + Returns: + Optional[str]: イベントタイプ("IN" or "OUT")、エラーの場合None + """ + state = self.get_state(track_id) + if not state: + return None + + # 信頼度を計算 + state.confidence = state.calculate_confidence(self.max_frame_gap) + + # カウント済みフラグを設定 + state.counted = True + + # 統計情報を更新 + if state.line1_direction == "IN": + self.total_in += 1 + elif state.line1_direction == "OUT": + self.total_out += 1 + + if state.confidence == "high": + self.high_confidence_count += 1 + elif state.confidence == "normal": + self.normal_confidence_count += 1 + + return state.line1_direction + + def cleanup_stale_tracks(self, current_frame: int): + """ + 古い追跡を削除 + + Args: + current_frame: 現在のフレーム番号 + """ + track_ids_to_remove = [] + + for track_id, state in self.states.items(): + frames_since_update = current_frame - state.last_update_frame + + if frames_since_update > self.cleanup_threshold: + track_ids_to_remove.append(track_id) + + for track_id in track_ids_to_remove: + del self.states[track_id] + + def get_summary(self) -> Dict: + """ + 統計サマリーを取得 + + Returns: + Dict: 統計情報 + """ + return { + "total_in": self.total_in, + "total_out": self.total_out, + "current_parked": self.total_in - self.total_out, + "high_confidence_events": self.high_confidence_count, + "normal_confidence_events": self.normal_confidence_count, + "active_tracks": len(self.states) + } + + def get_current_parked(self) -> int: + """ + 現在の駐車台数を取得 + + Returns: + int: 駐車台数 + """ + return self.total_in - self.total_out From ea5f37ebb4f11285683b9ddbce496831911955cf Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:44:18 +0900 Subject: [PATCH 06/57] =?UTF-8?q?[feat]=20GUI=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E5=BA=A7=E6=A8=99=E8=A8=AD=E5=AE=9A=E3=83=84=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - line_setup/setup_lines.py: 対話的にライン座標を設定 - OpenCVウィンドウでマウスクリックによる5点設定 - リアルタイムプレビュー表示 - .envファイルへの自動保存 - やり直し機能('r'キー) --- .../line_detection/line_setup/setup_lines.py | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100755 raspi/line_detection/line_setup/setup_lines.py diff --git a/raspi/line_detection/line_setup/setup_lines.py b/raspi/line_detection/line_setup/setup_lines.py new file mode 100755 index 00000000..b361c38a --- /dev/null +++ b/raspi/line_detection/line_setup/setup_lines.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +GUIライン座標設定ツール + +動画の最初のフレームを表示し、マウスクリックで以下の5点を設定: +1. Line1(入口側)の始点 +2. Line1(入口側)の終点 +3. Line2(駐車場側)の始点 +4. Line2(駐車場側)の終点 +5. 駐車場基準点(駐車場内の任意の点) + +設定完了後、.envファイルに自動保存 +""" + +import cv2 +import argparse +import os +from typing import List, Tuple, Optional + + +class LineSetupGUI: + """ライン座標設定GUI""" + + def __init__(self, video_path: str, env_path: str = None): + """ + Args: + video_path: 動画ファイルのパス + env_path: .envファイルのパス(Noneの場合はline_detection/.envを使用) + """ + self.video_path = video_path + self.env_path = env_path or os.path.join( + os.path.dirname(os.path.dirname(__file__)), + ".env" + ) + + # クリックされた点を保存 + self.points: List[Tuple[int, int]] = [] + + # 点のラベル + self.labels = [ + "Line1 始点(入口側)", + "Line1 終点(入口側)", + "Line2 始点(駐車場側)", + "Line2 終点(駐車場側)", + "駐車場基準点" + ] + + # 現在のフレーム + self.frame: Optional[cv2.Mat] = None + self.display_frame: Optional[cv2.Mat] = None + + # ウィンドウ名 + self.window_name = "ライン座標設定" + + def mouse_callback(self, event, x, y, flags, param): + """ + マウスクリックのコールバック + + Args: + event: イベントタイプ + x, y: クリック座標 + flags: フラグ + param: パラメータ + """ + if event == cv2.EVENT_LBUTTONDOWN: + # 左クリックで点を追加 + if len(self.points) < len(self.labels): + self.points.append((x, y)) + print(f"✓ {self.labels[len(self.points) - 1]}: ({x}, {y})") + + # 表示を更新 + self.update_display() + + # 5点すべて設定完了 + if len(self.points) == len(self.labels): + print("\n全ての点が設定されました!") + print("確認してください。問題なければウィンドウを閉じてください。") + print("やり直す場合は'r'キーを押してください。") + + def update_display(self): + """表示を更新""" + self.display_frame = self.frame.copy() + + # 設定済みの点を描画 + for i, point in enumerate(self.points): + color = (0, 255, 0) if i < 4 else (255, 0, 255) # Line点=緑、基準点=マゼンタ + cv2.circle(self.display_frame, point, 5, color, -1) + cv2.putText( + self.display_frame, + self.labels[i], + (point[0] + 10, point[1]), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + color, + 2 + ) + + # Line1を描画 + if len(self.points) >= 2: + cv2.line( + self.display_frame, + self.points[0], + self.points[1], + (0, 255, 0), # 緑 + 2 + ) + cv2.putText( + self.display_frame, + "Line1 (入口側)", + ((self.points[0][0] + self.points[1][0]) // 2, + (self.points[0][1] + self.points[1][1]) // 2 - 10), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 255, 0), + 2 + ) + + # Line2を描画 + if len(self.points) >= 4: + cv2.line( + self.display_frame, + self.points[2], + self.points[3], + (0, 255, 255), # 黄色 + 2 + ) + cv2.putText( + self.display_frame, + "Line2 (駐車場側)", + ((self.points[2][0] + self.points[3][0]) // 2, + (self.points[2][1] + self.points[3][1]) // 2 - 10), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 255, 255), + 2 + ) + + # 次にクリックする点の説明を表示 + if len(self.points) < len(self.labels): + instruction = f"次: {self.labels[len(self.points)]} をクリック" + cv2.putText( + self.display_frame, + instruction, + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 0, 255), + 2 + ) + + cv2.imshow(self.window_name, self.display_frame) + + def load_first_frame(self) -> bool: + """ + 動画の最初のフレームを読み込む + + Returns: + bool: 成功した場合True + """ + cap = cv2.VideoCapture(self.video_path) + + if not cap.isOpened(): + print(f"エラー: 動画ファイルを開けません: {self.video_path}") + return False + + ret, frame = cap.read() + cap.release() + + if not ret: + print(f"エラー: 動画フレームを読み込めません: {self.video_path}") + return False + + self.frame = frame + self.display_frame = frame.copy() + + return True + + def run(self) -> bool: + """ + GUIを実行 + + Returns: + bool: 成功した場合True + """ + # 最初のフレームを読み込み + if not self.load_first_frame(): + return False + + # ウィンドウを作成 + cv2.namedWindow(self.window_name) + cv2.setMouseCallback(self.window_name, self.mouse_callback) + + print("=" * 60) + print("ライン座標設定ツール") + print("=" * 60) + print("\n以下の順番で5点をクリックしてください:\n") + for i, label in enumerate(self.labels, 1): + print(f" {i}. {label}") + print("\n操作:") + print(" - 左クリック: 点を設定") + print(" - 'r'キー: やり直し") + print(" - 'q'キーまたはウィンドウを閉じる: 終了") + print("=" * 60) + + self.update_display() + + while True: + key = cv2.waitKey(1) & 0xFF + + # 'q'キーまたはウィンドウが閉じられた + if key == ord('q') or cv2.getWindowProperty(self.window_name, cv2.WND_PROP_VISIBLE) < 1: + break + + # 'r'キーでやり直し + if key == ord('r'): + print("\nやり直します...") + self.points = [] + self.update_display() + print("\n以下の順番で5点をクリックしてください:") + for i, label in enumerate(self.labels, 1): + print(f" {i}. {label}") + + cv2.destroyAllWindows() + + # 5点すべて設定されているかチェック + if len(self.points) != len(self.labels): + print(f"\n警告: 5点すべてが設定されていません({len(self.points)}/5)") + return False + + return True + + def save_to_env(self): + """設定を.envファイルに保存""" + # 環境変数の内容を作成 + env_content = f"""# プロジェクトパス +HOME_DIR={os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))} + +# YOLOモデル +MODEL_PATH=${{HOME_DIR}}/yolo_fine_tuning/runs/detect/train14/weights/best.pt +CONFIDENCE_THRESHOLD=0.3 + +# Line1設定(入口側ライン) +LINE1_START_X={self.points[0][0]} +LINE1_START_Y={self.points[0][1]} +LINE1_END_X={self.points[1][0]} +LINE1_END_Y={self.points[1][1]} + +# Line2設定(駐車場側ライン) +LINE2_START_X={self.points[2][0]} +LINE2_START_Y={self.points[2][1]} +LINE2_END_X={self.points[3][0]} +LINE2_END_Y={self.points[3][1]} + +# 駐車場基準点(駐車場側を定義) +PARKING_REF_X={self.points[4][0]} +PARKING_REF_Y={self.points[4][1]} + +# 検知パラメータ +MARGIN=1000.0 +MAX_FRAME_GAP=90 # 3秒@30fps +CLEANUP_THRESHOLD=150 # 5秒@30fps + +# 処理方式: hybrid固定 +METHOD=hybrid + +# 出力設定 +SAVE_VIDEO=true +SAVE_LOGS=true +SHOW_DISPLAY=false +""" + + # .envファイルに書き込み + with open(self.env_path, 'w', encoding='utf-8') as f: + f.write(env_content) + + print(f"\n✓ 設定を保存しました: {self.env_path}") + print("\n設定内容:") + print(f" Line1 (入口側): {self.points[0]} → {self.points[1]}") + print(f" Line2 (駐車場側): {self.points[2]} → {self.points[3]}") + print(f" 駐車場基準点: {self.points[4]}") + + +def main(): + """メイン関数""" + parser = argparse.ArgumentParser( + description="GUIでライン座標を設定" + ) + parser.add_argument( + "--video", + required=True, + help="動画ファイルのパス" + ) + parser.add_argument( + "--env", + default=None, + help=".envファイルのパス(デフォルト: line_detection/.env)" + ) + + args = parser.parse_args() + + # 動画ファイルの存在確認 + if not os.path.exists(args.video): + print(f"エラー: 動画ファイルが見つかりません: {args.video}") + return 1 + + # GUIを実行 + gui = LineSetupGUI(args.video, args.env) + + if gui.run(): + # 設定を保存 + gui.save_to_env() + print("\n完了!") + return 0 + else: + print("\nキャンセルされました。") + return 1 + + +if __name__ == "__main__": + exit(main()) From 139a0a9eb93a0390f35c8c9fce4cae180a448108 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:45:33 +0900 Subject: [PATCH 07/57] =?UTF-8?q?[feat]=20=E3=82=A2=E3=83=8E=E3=83=86?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E5=8B=95=E7=94=BB=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - result_output/video_writer.py: VideoAnnotatorクラス - Line1(緑)とLine2(黄色)の描画 - 車両代表点とtrack_idの表示 - カウント情報のオーバーレイ表示 - 処理時間、入出庫数、駐車台数、信頼度別統計の表示 --- .../result_output/video_writer.py | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 raspi/line_detection/result_output/video_writer.py diff --git a/raspi/line_detection/result_output/video_writer.py b/raspi/line_detection/result_output/video_writer.py new file mode 100644 index 00000000..1803f13e --- /dev/null +++ b/raspi/line_detection/result_output/video_writer.py @@ -0,0 +1,266 @@ +""" +アノテーション動画生成モジュール +検知結果を可視化した動画を生成 +""" + +import cv2 +import numpy as np +from typing import Dict, Tuple +from detection.config import Line +from detection.tracker import VehicleTracker, VehicleState + + +class VideoAnnotator: + """動画アノテーションクラス""" + + def __init__(self, + line1: Line, + line2: Line, + parking_ref_point: Tuple[float, float]): + """ + Args: + line1: Line1(入口側) + line2: Line2(駐車場側) + parking_ref_point: 駐車場基準点 + """ + self.line1 = line1 + self.line2 = line2 + self.parking_ref_point = parking_ref_point + + # 色定義 + self.COLOR_LINE1 = (0, 255, 0) # 緑 + self.COLOR_LINE2 = (0, 255, 255) # 黄色 + self.COLOR_PARKING_REF = (255, 0, 255) # マゼンタ + self.COLOR_VEHICLE_POINT = (255, 0, 0) # 青 + self.COLOR_TEXT = (255, 255, 255) # 白 + self.COLOR_BG = (0, 0, 0) # 黒 + + def draw_lines(self, frame: np.ndarray) -> np.ndarray: + """ + ライン を描画 + + Args: + frame: 入力フレーム + + Returns: + np.ndarray: ライン描画後のフレーム + """ + # Line1 (入口側) を緑で描画 + cv2.line( + frame, + tuple(map(int, self.line1.start)), + tuple(map(int, self.line1.end)), + self.COLOR_LINE1, + 3 + ) + # Line1のラベル + line1_mid = ( + int((self.line1.start[0] + self.line1.end[0]) / 2), + int((self.line1.start[1] + self.line1.end[1]) / 2) + ) + self._draw_text_with_background( + frame, + "Line1 (入口側)", + (line1_mid[0], line1_mid[1] - 10), + self.COLOR_LINE1 + ) + + # Line2 (駐車場側) を黄色で描画 + cv2.line( + frame, + tuple(map(int, self.line2.start)), + tuple(map(int, self.line2.end)), + self.COLOR_LINE2, + 3 + ) + # Line2のラベル + line2_mid = ( + int((self.line2.start[0] + self.line2.end[0]) / 2), + int((self.line2.start[1] + self.line2.end[1]) / 2) + ) + self._draw_text_with_background( + frame, + "Line2 (駐車場側)", + (line2_mid[0], line2_mid[1] - 10), + self.COLOR_LINE2 + ) + + # 駐車場基準点をマゼンタで描画 + cv2.circle( + frame, + tuple(map(int, self.parking_ref_point)), + 8, + self.COLOR_PARKING_REF, + -1 + ) + + return frame + + def draw_vehicle_states(self, + frame: np.ndarray, + tracker: VehicleTracker) -> np.ndarray: + """ + 車両状態を描画 + + Args: + frame: 入力フレーム + tracker: VehicleTracker + + Returns: + np.ndarray: 車両状態描画後のフレーム + """ + for track_id, state in tracker.states.items(): + if state.curr_point is None: + continue + + # 車両代表点を描画 + point = tuple(map(int, state.curr_point)) + cv2.circle(frame, point, 5, self.COLOR_VEHICLE_POINT, -1) + + # track_idと状態を表示 + label = f"ID:{track_id}" + if state.line1_direction: + label += f" {state.line1_direction}" + if state.confidence: + label += f" ({state.confidence})" + + self._draw_text_with_background( + frame, + label, + (point[0] + 10, point[1] - 5), + self.COLOR_VEHICLE_POINT, + font_scale=0.5 + ) + + return frame + + def draw_count_overlay(self, + frame: np.ndarray, + tracker: VehicleTracker, + frame_id: int, + processing_time_ms: float) -> np.ndarray: + """ + カウント情報をオーバーレイ表示 + + Args: + frame: 入力フレーム + tracker: VehicleTracker + frame_id: フレーム番号 + processing_time_ms: 処理時間(ms) + + Returns: + np.ndarray: オーバーレイ表示後のフレーム + """ + summary = tracker.get_summary() + + # 背景を半透明の黒で描画 + overlay = frame.copy() + cv2.rectangle(overlay, (10, 10), (350, 180), self.COLOR_BG, -1) + cv2.addWeighted(overlay, 0.7, frame, 0.3, 0, frame) + + # 統計情報を表示 + y = 35 + line_height = 25 + + texts = [ + f"Frame: {frame_id}", + f"IN: {summary['total_in']} OUT: {summary['total_out']}", + f"Parked: {summary['current_parked']}", + f"High: {summary['high_confidence_events']} Normal: {summary['normal_confidence_events']}", + f"Active: {summary['active_tracks']}", + f"Time: {processing_time_ms:.1f}ms" + ] + + for text in texts: + cv2.putText( + frame, + text, + (20, y), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + self.COLOR_TEXT, + 2 + ) + y += line_height + + return frame + + def annotate_frame(self, + frame: np.ndarray, + tracker: VehicleTracker, + frame_id: int, + processing_time_ms: float = 0.0) -> np.ndarray: + """ + フレームに全てのアノテーションを追加 + + Args: + frame: 入力フレーム + tracker: VehicleTracker + frame_id: フレーム番号 + processing_time_ms: 処理時間(ms) + + Returns: + np.ndarray: アノテーション後のフレーム + """ + # フレームをコピー + annotated = frame.copy() + + # ラインを描画 + annotated = self.draw_lines(annotated) + + # 車両状態を描画 + annotated = self.draw_vehicle_states(annotated, tracker) + + # カウント情報をオーバーレイ + annotated = self.draw_count_overlay( + annotated, + tracker, + frame_id, + processing_time_ms + ) + + return annotated + + def _draw_text_with_background(self, + frame: np.ndarray, + text: str, + position: Tuple[int, int], + color: Tuple[int, int, int], + font_scale: float = 0.6): + """ + 背景付きテキストを描画 + + Args: + frame: フレーム + text: テキスト + position: 位置 (x, y) + color: テキスト色 + font_scale: フォントスケール + """ + # テキストサイズを取得 + (text_width, text_height), baseline = cv2.getTextSize( + text, + cv2.FONT_HERSHEY_SIMPLEX, + font_scale, + 2 + ) + + # 背景を描画 + cv2.rectangle( + frame, + (position[0] - 2, position[1] - text_height - 2), + (position[0] + text_width + 2, position[1] + baseline + 2), + self.COLOR_BG, + -1 + ) + + # テキストを描画 + cv2.putText( + frame, + text, + position, + cv2.FONT_HERSHEY_SIMPLEX, + font_scale, + color, + 2 + ) From 3a12c3c61a7af48179e52ba6087c53a186f66d8a Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:46:41 +0900 Subject: [PATCH 08/57] =?UTF-8?q?[feat]=20=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=83=AD=E3=82=B0=E5=87=BA=E5=8A=9B=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85(JSON/CSV)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - result_output/event_logger.py: EventLoggerクラス - イベント記録(track_id, 種別, フレーム, タイムスタンプ, 信頼度) - JSON形式での保存(タイムスタンプ付きファイル名) - CSV形式での保存(Excelで開きやすい) - サマリー情報の出力(入出庫数、駐車台数、信頼度別統計) --- .../result_output/event_logger.py | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 raspi/line_detection/result_output/event_logger.py diff --git a/raspi/line_detection/result_output/event_logger.py b/raspi/line_detection/result_output/event_logger.py new file mode 100644 index 00000000..d43a6252 --- /dev/null +++ b/raspi/line_detection/result_output/event_logger.py @@ -0,0 +1,240 @@ +""" +イベントログ出力モジュール +検知イベントをJSON/CSV形式で保存 +""" + +import json +import csv +import os +from datetime import datetime +from typing import List, Dict, Any +from detection.tracker import VehicleState + + +class Event: + """イベントデータクラス""" + + def __init__(self, + track_id: int, + event_type: str, + frame_id: int, + timestamp_sec: float, + confidence: str, + line2_crossed: bool): + """ + Args: + track_id: トラッキングID + event_type: イベントタイプ("IN" or "OUT") + frame_id: フレーム番号 + timestamp_sec: タイムスタンプ(秒) + confidence: 信頼度("high" or "normal") + line2_crossed: Line2を交差したか + """ + self.track_id = track_id + self.event_type = event_type + self.frame_id = frame_id + self.timestamp_sec = timestamp_sec + self.confidence = confidence + self.line2_crossed = line2_crossed + + def to_dict(self) -> Dict[str, Any]: + """辞書に変換""" + return { + "track_id": self.track_id, + "event_type": self.event_type, + "frame_id": self.frame_id, + "timestamp_sec": round(self.timestamp_sec, 2), + "confidence": self.confidence, + "line2_crossed": self.line2_crossed + } + + +class EventLogger: + """イベントログ出力クラス""" + + def __init__(self, video_path: str = None): + """ + Args: + video_path: 動画ファイルのパス + """ + self.video_path = video_path + self.events: List[Event] = [] + self.start_time = datetime.now() + self.total_frames = 0 + self.total_processing_time_ms = 0.0 + + def record_event(self, + track_id: int, + event_type: str, + frame_id: int, + fps: float, + confidence: str, + line2_crossed: bool): + """ + イベントを記録 + + Args: + track_id: トラッキングID + event_type: イベントタイプ("IN" or "OUT") + frame_id: フレーム番号 + fps: FPS + confidence: 信頼度("high" or "normal") + line2_crossed: Line2を交差したか + """ + timestamp_sec = frame_id / fps if fps > 0 else 0.0 + + event = Event( + track_id=track_id, + event_type=event_type, + frame_id=frame_id, + timestamp_sec=timestamp_sec, + confidence=confidence, + line2_crossed=line2_crossed + ) + + self.events.append(event) + + def record_frame_time(self, processing_time_ms: float): + """ + フレーム処理時間を記録 + + Args: + processing_time_ms: 処理時間(ms) + """ + self.total_processing_time_ms += processing_time_ms + self.total_frames += 1 + + def get_summary(self, tracker_summary: Dict) -> Dict[str, Any]: + """ + サマリーを取得 + + Args: + tracker_summary: VehicleTrackerのサマリー + + Returns: + Dict: サマリー情報 + """ + avg_time = (self.total_processing_time_ms / self.total_frames + if self.total_frames > 0 else 0.0) + + return { + "total_in": tracker_summary["total_in"], + "total_out": tracker_summary["total_out"], + "current_parked": tracker_summary["current_parked"], + "high_confidence_events": tracker_summary["high_confidence_events"], + "normal_confidence_events": tracker_summary["normal_confidence_events"], + "avg_processing_time_ms": round(avg_time, 2) + } + + def save_json(self, output_dir: str, tracker_summary: Dict) -> str: + """ + JSONファイルに保存 + + Args: + output_dir: 出力ディレクトリ + tracker_summary: VehicleTrackerのサマリー + + Returns: + str: 保存したファイルのパス + """ + # 出力ディレクトリを作成 + os.makedirs(output_dir, exist_ok=True) + + # ファイル名を生成 + timestamp = self.start_time.strftime("%Y%m%d_%H%M%S") + filename = f"events_{timestamp}.json" + filepath = os.path.join(output_dir, filename) + + # JSONデータを作成 + data = { + "video_path": self.video_path or "unknown", + "processed_at": self.start_time.isoformat(), + "total_frames": self.total_frames, + "avg_processing_time_ms": round( + self.total_processing_time_ms / self.total_frames + if self.total_frames > 0 else 0.0, + 2 + ), + "events": [event.to_dict() for event in self.events], + "summary": self.get_summary(tracker_summary) + } + + # JSONファイルに書き込み + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + print(f"✓ JSONログを保存: {filepath}") + return filepath + + def save_csv(self, output_dir: str) -> str: + """ + CSVファイルに保存 + + Args: + output_dir: 出力ディレクトリ + + Returns: + str: 保存したファイルのパス + """ + # 出力ディレクトリを作成 + os.makedirs(output_dir, exist_ok=True) + + # ファイル名を生成 + timestamp = self.start_time.strftime("%Y%m%d_%H%M%S") + filename = f"events_{timestamp}.csv" + filepath = os.path.join(output_dir, filename) + + # CSVファイルに書き込み + with open(filepath, 'w', newline='', encoding='utf-8') as f: + if len(self.events) == 0: + # イベントがない場合はヘッダーのみ + writer = csv.writer(f) + writer.writerow([ + "track_id", + "event_type", + "frame_id", + "timestamp_sec", + "confidence", + "line2_crossed" + ]) + else: + # イベントがある場合 + fieldnames = [ + "track_id", + "event_type", + "frame_id", + "timestamp_sec", + "confidence", + "line2_crossed" + ] + writer = csv.DictWriter(f, fieldnames=fieldnames) + + # ヘッダーを書き込み + writer.writeheader() + + # イベントを書き込み + for event in self.events: + writer.writerow(event.to_dict()) + + print(f"✓ CSVログを保存: {filepath}") + return filepath + + def print_summary(self, tracker_summary: Dict): + """ + サマリーを出力 + + Args: + tracker_summary: VehicleTrackerのサマリー + """ + summary = self.get_summary(tracker_summary) + + print("\n" + "=" * 60) + print("処理結果サマリー") + print("=" * 60) + print(f"入庫: {summary['total_in']}") + print(f"出庫: {summary['total_out']}") + print(f"現在駐車台数: {summary['current_parked']}") + print(f"高信頼度イベント: {summary['high_confidence_events']}") + print(f"通常信頼度イベント: {summary['normal_confidence_events']}") + print(f"平均処理時間: {summary['avg_processing_time_ms']:.2f}ms/frame") + print("=" * 60) From 3c59680afda8e0583c39035f9e88e201e6ed22ad Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:48:32 +0900 Subject: [PATCH 09/57] =?UTF-8?q?[feat]=20=E3=83=A1=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=83=91=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.py: 2ライン検知システムのメインエントリーポイント - YOLOv8トラッキング統合 - フレーム毎のライン交差検知とイベント判定 - ハイブリッド方式による入出庫カウント - アノテーション動画生成 - JSON/CSVログ出力 - コマンドラインインターフェース(動画ファイル/カメラ対応) - 進捗表示とエラーハンドリング --- raspi/line_detection/main.py | 316 +++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100755 raspi/line_detection/main.py diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py new file mode 100755 index 00000000..932a08cd --- /dev/null +++ b/raspi/line_detection/main.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +2ライン検知システム メイン処理 + +YOLOv8トラッキングと外積法を使用した高精度な入出庫カウント +""" + +import cv2 +import argparse +import os +import sys +import time +from ultralytics import YOLO + +# 親ディレクトリをパスに追加 +sys.path.insert(0, os.path.dirname(__file__)) + +from detection.config import Config +from detection.line_crossing import LineCrossingDetector, get_vehicle_point +from detection.tracker import VehicleTracker +from result_output.video_writer import VideoAnnotator +from result_output.event_logger import EventLogger + + +def process_video(video_path: str, config: Config, output_dir: str): + """ + 動画を処理 + + Args: + video_path: 入力動画のパス + config: 設定 + output_dir: 出力ディレクトリ + """ + print("\n" + "=" * 60) + print("2ライン検知システム") + print("=" * 60) + print(f"入力動画: {video_path}") + print(f"出力先: {output_dir}") + print("=" * 60 + "\n") + + # 1. 初期化 + print("初期化中...") + + # YOLOモデルをロード + model = YOLO(config.model_path) + print(f"✓ YOLOモデル読み込み: {config.model_path}") + + # 動画キャプチャを開く + cap = cv2.VideoCapture(video_path if video_path else 0) + + if not cap.isOpened(): + print(f"エラー: 動画を開けません: {video_path}") + return + + # 動画情報を取得 + 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)) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + print(f"✓ 動画情報: {width}x{height} @ {fps}fps, {total_frames}フレーム") + + # トラッカーを初期化 + tracker = VehicleTracker( + max_frame_gap=config.max_frame_gap, + cleanup_threshold=config.cleanup_threshold + ) + print(f"✓ トラッカー初期化: max_frame_gap={config.max_frame_gap}, cleanup={config.cleanup_threshold}") + + # ライン交差検知器を初期化 + detector = LineCrossingDetector( + line1=config.line1, + line2=config.line2, + parking_ref_point=config.parking_ref_point, + margin=config.margin + ) + print(f"✓ ライン検知器初期化: margin={config.margin}") + + # アノテーターを初期化 + annotator = VideoAnnotator( + line1=config.line1, + line2=config.line2, + parking_ref_point=config.parking_ref_point + ) + print("✓ アノテーター初期化") + + # イベントロガーを初期化 + logger = EventLogger(video_path=video_path) + print("✓ イベントロガー初期化") + + # 出力動画ライターを初期化 + out = None + if config.save_video: + os.makedirs(os.path.join(output_dir, "videos"), exist_ok=True) + output_video_path = os.path.join( + output_dir, + "videos", + f"annotated_{os.path.basename(video_path)}" + ) + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + out = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height)) + print(f"✓ 出力動画: {output_video_path}") + + print("\n処理開始...\n") + + # 2. フレーム毎処理 + frame_id = 0 + + while cap.isOpened(): + start_time = time.perf_counter() + + ret, frame = cap.read() + if not ret: + break + + # 2.1 YOLO検知+トラッキング + results = model.track( + frame, + persist=True, + conf=config.confidence_threshold, + verbose=False + ) + + # 2.2 各車両の処理 + if results[0].boxes.id is not None: + track_ids = results[0].boxes.id.int().tolist() + bboxes = results[0].boxes.xyxy.tolist() + + for track_id, bbox in zip(track_ids, bboxes): + # 車両代表点取得 + vehicle_point = get_vehicle_point(bbox) + + # 状態更新 + state = tracker.update(track_id, vehicle_point, frame_id) + + # ライン交差検知 + if state.prev_point is not None: + # Line1交差検知 + line1_dir = detector.detect_line1_crossing( + state.prev_point, + state.curr_point + ) + + # Line2交差検知 + line2_crossed = detector.detect_line2_crossing( + state.prev_point, + state.curr_point + ) + + # Line1交差イベントを記録 + if line1_dir and not state.counted: + state.record_line1_crossing(line1_dir, frame_id) + + # Line2交差を記録 + if line2_crossed: + state.record_line2_crossing(frame_id) + + # ハイブリッド方式でイベント判定 + if tracker.should_count_event(state): + # カウント済みとしてマーク + event_type = tracker.mark_as_counted(track_id) + + # イベントをログに記録 + logger.record_event( + track_id=track_id, + event_type=event_type, + frame_id=frame_id, + fps=fps, + confidence=state.confidence, + line2_crossed=state.line2_passed + ) + + print(f"[Frame {frame_id}] ID:{track_id} {event_type} (信頼度: {state.confidence})") + + # 2.3 古い追跡をクリーンアップ + tracker.cleanup_stale_tracks(frame_id) + + # 2.4 処理時間を記録 + processing_time_ms = (time.perf_counter() - start_time) * 1000 + logger.record_frame_time(processing_time_ms) + + # 2.5 アノテーション + annotated_frame = annotator.annotate_frame( + frame, + tracker, + frame_id, + processing_time_ms + ) + + # 2.6 出力 + if config.save_video and out: + out.write(annotated_frame) + + if config.show_display: + cv2.imshow("2ライン検知", annotated_frame) + if cv2.waitKey(1) & 0xFF == ord('q'): + print("\nユーザーによる中断") + break + + # 進捗表示 + if frame_id % 30 == 0: + progress = (frame_id / total_frames * 100) if total_frames > 0 else 0 + print(f"処理中... {frame_id}/{total_frames}フレーム ({progress:.1f}%)") + + frame_id += 1 + + # 3. クリーンアップ + cap.release() + if out: + out.release() + if config.show_display: + cv2.destroyAllWindows() + + print("\n処理完了!") + + # 4. ログを保存 + if config.save_logs: + log_dir = os.path.join(output_dir, "logs") + logger.save_json(log_dir, tracker.get_summary()) + logger.save_csv(log_dir) + + # 5. サマリーを表示 + logger.print_summary(tracker.get_summary()) + + +def main(): + """メイン関数""" + parser = argparse.ArgumentParser( + description="2ライン検知システム - 高精度な駐車場入出庫カウント" + ) + parser.add_argument( + "--input", + help="入力動画のパス" + ) + parser.add_argument( + "--camera", + type=int, + help="カメラデバイスID(0=デフォルトカメラ)" + ) + parser.add_argument( + "--output", + default="data/outputs", + help="出力ディレクトリ(デフォルト: data/outputs)" + ) + parser.add_argument( + "--env", + default=None, + help=".envファイルのパス(デフォルト: カレントディレクトリの.env)" + ) + parser.add_argument( + "--display", + action="store_true", + help="処理中の動画を表示" + ) + + args = parser.parse_args() + + # 入力ソースのチェック + if args.input and args.camera is not None: + print("エラー: --inputと--cameraは同時に指定できません") + return 1 + + if not args.input and args.camera is None: + print("エラー: --inputまたは--cameraを指定してください") + print("\n使用例:") + print(" python main.py --input data/inputs/test.mp4") + print(" python main.py --camera 0 --display") + return 1 + + # 入力ソースを決定 + video_path = args.input if args.input else args.camera + + # 出力ディレクトリの絶対パス + output_dir = os.path.abspath(args.output) + + # 設定を読み込み + try: + if args.env: + config = Config.from_env(args.env) + else: + # カレントディレクトリの.envを探す + env_path = os.path.join(os.path.dirname(__file__), ".env") + if not os.path.exists(env_path): + print(f"エラー: .envファイルが見つかりません: {env_path}") + print("\nまず line_setup/setup_lines.py を実行してライン座標を設定してください:") + print(" python line_setup/setup_lines.py --video data/inputs/test.mp4") + return 1 + config = Config.from_env(env_path) + + # 設定を検証 + config.validate() + + # 表示設定を上書き + if args.display: + config.show_display = True + + except Exception as e: + print(f"設定エラー: {e}") + return 1 + + # 動画を処理 + try: + process_video(video_path, config, output_dir) + return 0 + except KeyboardInterrupt: + print("\n\nユーザーによる中断") + return 1 + except Exception as e: + print(f"\nエラーが発生しました: {e}") + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From f76b64e3526f1527248cf21392da6f74f1c38a14 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:49:58 +0900 Subject: [PATCH 10/57] =?UTF-8?q?[docs]=20README.md=E3=82=92=E4=BD=9C?= =?UTF-8?q?=E6=88=90(=E4=BD=BF=E7=94=A8=E6=96=B9=E6=B3=95=E3=81=A8?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E6=89=8B=E9=A0=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: 2ライン検知システムの完全なドキュメント - セットアップ手順(依存関係、ライン設定、動画配置) - 使用方法(基本/表示/カメラ/出力先指定) - 出力ファイル形式の説明(JSON/CSV) - 設定パラメータの詳細 - アルゴリズムの解説(外積法、ハイブリッド方式) - トラブルシューティング --- raspi/line_detection/README.md | 256 +++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 raspi/line_detection/README.md diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md new file mode 100644 index 00000000..89c77f5b --- /dev/null +++ b/raspi/line_detection/README.md @@ -0,0 +1,256 @@ +# 2ライン検知システム + +駐車場の出入り検知精度を向上させる2ライン検知システムです。Line1(入口側)とLine2(駐車場側)の2本のラインを使用し、車両の通過順序と方向から高精度な入出庫カウントを実現します。 + +## 特徴 + +- **ハイブリッド方式**: Line1が主判定、Line2が信頼度を付与 +- **外積法による高精度判定**: 数学的に正確なライン交差検知 +- **GUIライン設定ツール**: 動画を見ながら対話的にライン座標を設定 +- **YOLOv8トラッキング**: 最新の物体検知・追跡技術 +- **詳細なログ出力**: JSON/CSV形式で解析しやすい +- **リアルタイム可視化**: アノテーション付き動画の生成 + +## ディレクトリ構造 + +``` +raspi/line_detection/ +├── .env # 環境設定 +├── .env.template # 設定テンプレート +├── requirements.txt # 依存パッケージ +├── README.md # このファイル +├── main.py # メイン処理 +│ +├── line_setup/ # ライン座標設定ツール +│ └── setup_lines.py # GUIでライン位置を設定 +│ +├── detection/ # 車両検知・ライン交差判定 +│ ├── config.py # 設定管理 +│ ├── line_crossing.py # ライン交差検知(外積法) +│ └── tracker.py # 車両トラッキング・状態管理 +│ +├── result_output/ # 結果出力 +│ ├── video_writer.py # アノテーション動画生成 +│ └── event_logger.py # イベントログ出力 +│ +└── data/ # データ + ├── inputs/ # 入力動画(.mp4) + └── outputs/ # 出力結果 + ├── videos/ # アノテーション済み動画 + └── logs/ # イベントログ(JSON/CSV) +``` + +## セットアップ + +### 1. 依存パッケージのインストール + +```bash +cd /path/to/tracking-parking/raspi/line_detection +pip install -r requirements.txt +``` + +### 2. ライン座標の設定 + +GUIツールを使って2本のラインと駐車場基準点を設定します: + +```bash +python line_setup/setup_lines.py --video data/inputs/test.mp4 +``` + +**操作方法:** +1. 動画の最初のフレームが表示されます +2. 以下の順番で5点をクリックしてください: + - **Line1 始点** (入口側ライン) + - **Line1 終点** (入口側ライン) + - **Line2 始点** (駐車場側ライン) + - **Line2 終点** (駐車場側ライン) + - **駐車場基準点** (駐車場内の任意の点) +3. 設定が`.env`ファイルに自動保存されます + +**キー操作:** +- `r`: やり直し +- `q`: 終了 + +### 3. 動画の配置 + +処理したい動画を`data/inputs/`に配置します: + +```bash +cp /path/to/your/video.mp4 data/inputs/ +``` + +## 使用方法 + +### 基本的な使用方法 + +```bash +python main.py --input data/inputs/test.mp4 +``` + +### リアルタイム表示 + +処理中の動画を表示しながら実行: + +```bash +python main.py --input data/inputs/test.mp4 --display +``` + +### カメラからリアルタイム処理 + +```bash +python main.py --camera 0 --display +``` + +### 出力先を指定 + +```bash +python main.py --input data/inputs/test.mp4 --output /path/to/output +``` + +## 出力ファイル + +### アノテーション動画 (`data/outputs/videos/`) + +元の動画に以下の情報を重ねて表示: +- Line1 (緑色) - 入口側ライン +- Line2 (黄色) - 駐車場側ライン +- 車両代表点とtrack_ID +- リアルタイムカウント(入庫/出庫/駐車台数) +- 処理時間 + +### イベントログ JSON (`data/outputs/logs/events_YYYYMMDD_HHMMSS.json`) + +```json +{ + "video_path": "data/inputs/test.mp4", + "processed_at": "2026-06-24T10:30:00", + "total_frames": 900, + "avg_processing_time_ms": 48.5, + "events": [ + { + "track_id": 1, + "event_type": "IN", + "frame_id": 145, + "timestamp_sec": 4.83, + "confidence": "high", + "line2_crossed": true + } + ], + "summary": { + "total_in": 5, + "total_out": 3, + "current_parked": 2, + "high_confidence_events": 6, + "normal_confidence_events": 2 + } +} +``` + +### イベントログ CSV (`data/outputs/logs/events_YYYYMMDD_HHMMSS.csv`) + +Excelで開きやすいCSV形式: + +```csv +track_id,event_type,frame_id,timestamp_sec,confidence,line2_crossed +1,IN,145,4.83,high,true +2,OUT,203,6.77,normal,false +``` + +## 設定パラメータ + +`.env`ファイルで以下のパラメータを調整できます: + +### YOLOモデル設定 + +```bash +MODEL_PATH=/path/to/yolov8s.pt +CONFIDENCE_THRESHOLD=0.3 # 検知信頼度閾値(0.0-1.0) +``` + +### ライン座標 + +`setup_lines.py`で自動設定されます。 + +### 検知パラメータ + +```bash +MARGIN=1000.0 # ライン交差判定のマージン +MAX_FRAME_GAP=90 # Line1とLine2の最大フレーム差(3秒@30fps) +CLEANUP_THRESHOLD=150 # 古い追跡をクリーンアップ(5秒@30fps) +``` + +### 出力設定 + +```bash +SAVE_VIDEO=true # アノテーション動画を保存 +SAVE_LOGS=true # ログを保存 +SHOW_DISPLAY=false # リアルタイム表示 +``` + +## アルゴリズム + +### 外積法によるライン交差検知 + +2D平面上のベクトル外積を使用してライン交差を判定: + +``` +外積 = (line_end - line_start) × (point - line_start) +``` + +- 外積 > 0: ポイントはラインの片側 +- 外積 < 0: ポイントはラインの反対側 +- 外積の符号が変化 = ライン交差 + +### ハイブリッド方式の判定ロジック + +``` +IF Line1を交差: + IF 方向 == IN: + IF Line2もLine1の後に交差(max_frame_gap以内): + -> 入庫(信頼度: HIGH) + ELSE: + -> 入庫(信頼度: NORMAL) + + IF 方向 == OUT: + IF Line2がLine1の前に交差(max_frame_gap以内): + -> 出庫(信頼度: HIGH) + ELSE: + -> 出庫(信頼度: NORMAL) + +IF Line2のみ交差(Line1交差なし): + -> カウントしない(駐車スペース内の移動) +``` + +## トラブルシューティング + +### `.env`ファイルが見つからない + +``` +エラー: .envファイルが見つかりません +``` + +**解決方法:** まず`setup_lines.py`を実行してライン座標を設定してください。 + +### YOLOモデルが見つからない + +``` +設定エラー: モデルファイルが見つかりません +``` + +**解決方法:** `.env`の`MODEL_PATH`を正しいパスに修正してください。 + +### カウントが不正確 + +**考えられる原因:** +1. ライン位置が適切でない → `setup_lines.py`で再設定 +2. `MARGIN`が大きすぎる/小さすぎる → `.env`で調整 +3. `MAX_FRAME_GAP`が適切でない → 車両の通過速度に合わせて調整 + +## 参考 + +- [GitHub Issue #88](https://github.com/NUTFes/tracking-parking/issues/88) - 設計仕様 +- [Ultralytics YOLOv8](https://github.com/ultralytics/ultralytics) - 物体検知モデル + +## ライセンス + +このプロジェクトのライセンスについては、リポジトリのルートディレクトリを参照してください。 From 37a63c181882a10c7bc74dd7262a1956084bacb5 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:50:40 +0900 Subject: [PATCH 11/57] =?UTF-8?q?[chore]=20.gitignore=E3=81=AB2=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E6=A4=9C=E7=9F=A5=E3=82=B7=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=83=A0=E3=81=AE=E5=87=BA=E5=8A=9B=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 入力動画ファイル(mp4/avi)を除外 - 出力動画ファイルを除外 - イベントログ(JSON/CSV)を除外 --- .gitignore | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2afefa6a..0211a9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,11 @@ __pycache__/ .env raspi/result/prediction_visual.png *.pt -.venv/ \ No newline at end of file +.venv/ + +# 2ライン検知システムの出力ファイル +raspi/line_detection/data/inputs/*.mp4 +raspi/line_detection/data/inputs/*.avi +raspi/line_detection/data/outputs/videos/*.mp4 +raspi/line_detection/data/outputs/logs/*.json +raspi/line_detection/data/outputs/logs/*.csv \ No newline at end of file From 02db0a412393686680e7984f0b92d118f84529be Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:56:07 +0900 Subject: [PATCH 12/57] =?UTF-8?q?[chore]=20uv=E3=82=92=E4=BD=BF=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E4=BE=9D=E5=AD=98=E3=83=91=E3=83=83=E3=82=B1=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E7=AE=A1=E7=90=86=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject.toml: 2ライン検知システムの依存パッケージを追加 - ultralytics, opencv-python, python-dotenv, numpy, torch, torchvision, pandas - README.md: インストール方法をuv syncに変更 --- pyproject.toml | 16 +++++++++++++++- raspi/line_detection/README.md | 16 ++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f4d76561..84a798a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,18 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + # YOLOv8とトラッキング + "ultralytics>=8.2.52", + # 動画処理 + "opencv-python>=4.8.1", + # 設定管理 + "python-dotenv>=1.0.0", + # 数値演算 + "numpy>=1.24.4,<2.0", + # 深層学習バックエンド + "torch>=2.2.2", + "torchvision>=0.17.2", + # データ処理 + "pandas>=2.2.1", +] diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md index 89c77f5b..ed69643d 100644 --- a/raspi/line_detection/README.md +++ b/raspi/line_detection/README.md @@ -44,11 +44,23 @@ raspi/line_detection/ ### 1. 依存パッケージのインストール +プロジェクトルートディレクトリ(`tracking-parking/`)でuvを使ってインストールします: + ```bash -cd /path/to/tracking-parking/raspi/line_detection -pip install -r requirements.txt +cd /path/to/tracking-parking +uv sync ``` +これにより、`pyproject.toml`に定義された2ライン検知システムに必要な依存パッケージがすべてインストールされます。 + +**インストールされるパッケージ:** +- ultralytics (YOLOv8) +- opencv-python (動画処理) +- python-dotenv (設定管理) +- numpy (数値演算) +- torch, torchvision (深層学習) +- pandas (データ処理) + ### 2. ライン座標の設定 GUIツールを使って2本のラインと駐車場基準点を設定します: From c8338a0835df9c7272043a62ab82732d1193dab3 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:29:34 +0900 Subject: [PATCH 13/57] =?UTF-8?q?[feat]=20uv.lock=E3=81=AB=E3=81=A6?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E5=86=85?= =?UTF-8?q?=E3=81=AE=E4=BE=9D=E5=AD=98=E9=96=A2=E4=BF=82=E3=82=92=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uv.lock | 1336 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1336 insertions(+) diff --git a/uv.lock b/uv.lock index eccd5e4f..70cf3f4b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,1344 @@ version = 1 revision = 2 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137, upload-time = "2026-06-12T02:28:37.93Z" }, + { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514, upload-time = "2026-06-12T02:28:40.028Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005, upload-time = "2026-06-12T02:28:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459, upload-time = "2026-06-12T02:28:44.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160, upload-time = "2026-06-12T02:28:46.564Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186, upload-time = "2026-06-12T02:28:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349, upload-time = "2026-06-12T02:28:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921, upload-time = "2026-06-12T02:28:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190, upload-time = "2026-06-12T02:28:55.623Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181, upload-time = "2026-06-12T02:28:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715, upload-time = "2026-06-12T02:29:00.555Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427, upload-time = "2026-06-12T02:29:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809, upload-time = "2026-06-12T02:29:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226, upload-time = "2026-06-12T02:29:07.033Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-ml-py" +version = "13.610.43" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b5/a8fbc356f768fa5c9cfd646668fd7d34bf55bdd1c6e20754642a64d930d4/nvidia_ml_py-13.610.43.tar.gz", hash = "sha256:65437eb73d68d0c62c931ca4d45038472faff03bd0b8729abba4b899f70d60f2", size = 52109, upload-time = "2026-06-01T18:54:08.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/45/caa600acfab94560807a20a64b5830d2cd3c3202b7f1328644d70b7d6bd8/nvidia_ml_py-13.610.43-py3-none-any.whl", hash = "sha256:f13c72698edef492f985cc225f14faafe68ae065a2e407f45bdf6f4b9b43fde8", size = 53163, upload-time = "2026-06-01T18:54:07.704Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322, upload-time = "2025-01-16T13:52:25.887Z" }, + { url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197, upload-time = "2025-01-16T13:55:21.222Z" }, + { url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439, upload-time = "2025-01-16T13:51:35.822Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597, upload-time = "2025-01-16T13:52:08.836Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337, upload-time = "2025-01-16T13:52:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.14'" }, + { name = "pytz", marker = "python_full_version >= '3.14'" }, + { name = "tzdata", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.14' and sys_platform == 'darwin'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.14'" }, + { name = "python-dateutil", marker = "python_full_version < '3.14'" }, + { name = "tzdata", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "polars" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "torch" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951, upload-time = "2026-06-17T21:07:49.309Z" }, + { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" }, + { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" }, + { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/63/b7/1b49fe7086ea36839cc80abc43174c43d0ab6f676c0891c871c162f44fe3/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", size = 88010025, upload-time = "2026-06-17T21:07:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/d7/06/5b44063a6545036dcc680d2d303b137d9176cfb2cc1e1863e3ef94abeb52/torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91", size = 426392891, upload-time = "2026-06-17T21:05:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dd/c9ce9a4b0eb3c5bb92d9ea56766e2c22559f0b45171149188494edcce80f/torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad", size = 532272494, upload-time = "2026-06-17T21:06:22.72Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/f3a601fc1b1f663ff269bfe553654e638651939aa6563e8daa7167c33098/torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3", size = 122987254, upload-time = "2026-06-17T21:06:33.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/b8087556cf81ddd808dbeb34afb8396d7ae7a1694ab489f08b1a0004e7d0/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", size = 88303173, upload-time = "2026-06-17T21:05:06.603Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/fe09d1699fbed2afa10ebc692ff2b99d113f2605b6748cea633989e2789a/torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9", size = 426404009, upload-time = "2026-06-17T21:04:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/0ce4f6c1962c60ded7270e0a9eb560fb615c92b89d332cf9e3dff36d5ecc/torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33", size = 532184292, upload-time = "2026-06-17T21:05:17.526Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/e384c12aba30320ca92aaaf557456cbcb26f04b4df307728bb8f019f5000/torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e", size = 123231142, upload-time = "2026-06-17T21:05:27.061Z" }, +] + +[[package]] +name = "torchvision" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/82/2e8fdc19e4f0bbe31d403a55d78318bcea4afcd3083e1e4700ef61ebb893/torchvision-0.27.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:448abfc3baba984da4577f737209e445da6be93e3b5f4799d90162bf61e3f485", size = 1852105, upload-time = "2026-06-17T21:09:33.695Z" }, + { url = "https://files.pythonhosted.org/packages/43/42/103fa8f9366cfd1329fe449d6b1a25a640c0c17862ed48f21c4af94af322/torchvision-0.27.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9edfb5a549fc2f30ccadb24eca907901e92e426c91a59316be6703a9360e5098", size = 7830902, upload-time = "2026-06-17T21:09:29.739Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/fa6052a42110a3657fc94073648da6171220469f4bf9f27e6a0b9378075c/torchvision-0.27.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ae3d49e57c4abc8eafc1a1971f80fc4948a6268fa69340737ca4466936def080", size = 7664211, upload-time = "2026-06-17T21:09:17.206Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/27aca854da7e536a339f46bab1ef67823ac2ac97c59ab2b3203b373d46cf/torchvision-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b6e3aa98b7433506bbce1d0d05cb13ec787fc6eb8c5fbd998b26ce05f047543", size = 4079076, upload-time = "2026-06-17T21:09:15.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/bb/b21e0f598ca191bb2a9e9fda2fee37c06ad113313b43c6769dbefa0e921d/torchvision-0.27.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d60311a6d08df905f9656a3a312f0a8f55f0d46321bc737bad30a8dec9644309", size = 1852110, upload-time = "2026-06-17T21:09:22.577Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/d61171daa5d6cd5f9315f84f9ef947b047a9fdf283d53241327045a8dd6d/torchvision-0.27.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:08aa33bc8e062cca32aefa90ac714916c5a855cbe1ab4c6148fc0453eb40ca5a", size = 7789476, upload-time = "2026-06-17T21:09:13.105Z" }, + { url = "https://files.pythonhosted.org/packages/b8/dc/b21d7801562c23a770e7037989814582f22ca4db479204293561de4b62e8/torchvision-0.27.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:916448be4b19676677b0dbf47d08f68b7955ea0abec7fc79340c31e217a824ba", size = 7664256, upload-time = "2026-06-17T21:09:07.549Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b3/4386976ff77eda55f0aed504a288564f3ff8d170b6db49ee22e172eddfac/torchvision-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:18bc906235bfa901c135acd239f05b8c8ab90d502830cf1ef2cba3301e1f8a23", size = 4150710, upload-time = "2026-06-17T21:09:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/74/1d237c61f665bf46d02e15f67c9d40be42b1b634f87164b9cefd257450e7/torchvision-0.27.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9f5ef59ad60e695796eca6b64e97cb9b21b9d5463cac5ac0ef86cfb72b6e5db9", size = 1852112, upload-time = "2026-06-17T21:09:21.445Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/f0d772e7ed85891f084755bd5d7f6f7fd279992a02652c653c1c8429dd84/torchvision-0.27.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ab2f8047c2da5bf6742fec6da86840e5feaeb0cea76930d0536f3520df31e166", size = 7789751, upload-time = "2026-06-17T21:09:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/76/68/3febd41b6eef453a83fb7a0178446334fbb0405eb4b0c40b00efaf99a2dc/torchvision-0.27.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b44ef28ad1963f8cba5bf82f3564c454c74be300df9f79efa43f773312d17d6c", size = 7664350, upload-time = "2026-06-17T21:09:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/52/49/a23e199faf29e42a90f7d6b76437ade5d17e3185da3c64d368973ba8243e/torchvision-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:b3e9bc71854fddbf94ddb69ed8d88983945f3f28f78ee104214b0088669af66a", size = 4177297, upload-time = "2026-06-17T21:09:10.273Z" }, + { url = "https://files.pythonhosted.org/packages/ba/48/b3240eaf0fe3676dcf677ce8930ef477fe77d7f69ebe58ca8d0941384952/torchvision-0.27.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c2fd9902f23b56b6ac667213171672fb6c89287ff011918b04af053852a2c4eb", size = 1852118, upload-time = "2026-06-17T21:09:20.223Z" }, + { url = "https://files.pythonhosted.org/packages/73/01/6c8f3158994a9e5bb0c7b1bacc361d60e015ad79487af88fa4d7ce72c2b6/torchvision-0.27.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8abb6d5cacd56486ca2240e5580750e53ac559412e472ea6a3cee83231a77ca7", size = 7791242, upload-time = "2026-06-17T21:09:02.062Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/f66733fc411a9ce070c0d899c1ae562ff11654a0bc708511e23efe9d6872/torchvision-0.27.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d11da1ce8a5cc7fc527f2d5e0fe25efba93687897fe9339382b593910b1d1c6e", size = 7664934, upload-time = "2026-06-17T21:09:06.221Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/d6179812ec52b70a7a8f5e99fe7937895d28c535106df1ca0d03f5f51425/torchvision-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:12deaee20d0d9dec6302025d3f93354266befeb692f5c50bca0137b395598b9e", size = 4284412, upload-time = "2026-06-17T21:09:08.989Z" }, +] [[package]] name = "tracking-parking" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "python-dotenv" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "ultralytics" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=1.24.4,<2.0" }, + { name = "opencv-python", specifier = ">=4.8.1" }, + { name = "pandas", specifier = ">=2.2.1" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "torch", specifier = ">=2.2.2" }, + { name = "torchvision", specifier = ">=0.17.2" }, + { name = "ultralytics", specifier = ">=8.2.52" }, +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "ultralytics" +version = "8.4.75" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "nvidia-ml-py" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "polars" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scipy" }, + { name = "torch" }, + { name = "torchvision" }, + { name = "ultralytics-thop" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/0a/a2b95ded35da98f1671adbb8e1a328deca191401e9efab3e6d06bf39b9b0/ultralytics-8.4.75.tar.gz", hash = "sha256:a252d424d469c08b06822861fb5905e72d9e489d538a0cd6531e74b3ed99fedb", size = 1108631, upload-time = "2026-06-21T19:28:32.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/8e/fae8d55844fad73eca40fdaa5cea9ae9abf843e40864025275283af55f8c/ultralytics-8.4.75-py3-none-any.whl", hash = "sha256:30fab7c1ac5b16a4604bc77e5ab05ca38417b071639871b5ef1d75cc5e0a2e1f", size = 1317743, upload-time = "2026-06-21T19:28:28.565Z" }, +] + +[[package]] +name = "ultralytics-thop" +version = "2.0.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c6/d25cb53e141242f74950744179c21b8798bb09b9e7161465ecda4f577ddf/ultralytics_thop-2.0.20.tar.gz", hash = "sha256:f3595e0d8c6fd0b9f62fc2cd9be921755e2649a05c34f1fabaea0bff7295d641", size = 34682, upload-time = "2026-06-06T11:42:42.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/a9/5441a28af447c0ffe193f1f42dfebe2a518ab3e160e9e0d164e8901edead/ultralytics_thop-2.0.20-py3-none-any.whl", hash = "sha256:ef1b326404aeb10954f50db58982ec8dcab5946ee04a9b10e1107ec7ba4d5c22", size = 28954, upload-time = "2026-06-06T11:42:40.796Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From ede1468823d1333afdf068d299b5ad4c3d9fb73a Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:49:54 +0900 Subject: [PATCH 14/57] =?UTF-8?q?[fix]=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=A4=9C=E7=9F=A5=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=81=AE?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?.gitignore=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 0211a9f9..76ec6e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,4 @@ raspi/result/prediction_visual.png .venv/ # 2ライン検知システムの出力ファイル -raspi/line_detection/data/inputs/*.mp4 -raspi/line_detection/data/inputs/*.avi -raspi/line_detection/data/outputs/videos/*.mp4 -raspi/line_detection/data/outputs/logs/*.json -raspi/line_detection/data/outputs/logs/*.csv \ No newline at end of file +raspi/line_detection/data/* \ No newline at end of file From 43e4cca9cac8cb98e13c2123696151c73ad6a4ce Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:18:17 +0900 Subject: [PATCH 15/57] =?UTF-8?q?[feat]=20lap=E3=83=91=E3=83=83=E3=82=B1?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=92=E4=BE=9D=E5=AD=98=E9=96=A2=E4=BF=82?= =?UTF-8?q?=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + uv.lock | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 84a798a2..69251a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,4 +18,5 @@ dependencies = [ "torchvision>=0.17.2", # データ処理 "pandas>=2.2.1", + "lap>=0.5.13", ] diff --git a/uv.lock b/uv.lock index 70cf3f4b..a7e1dd3c 100644 --- a/uv.lock +++ b/uv.lock @@ -403,6 +403,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] +[[package]] +name = "lap" +version = "0.5.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/ae/5cc637c2e5158b7dcf1a9744d33b11dfc21d9309931169402f573e4d1ee3/lap-0.5.13.tar.gz", hash = "sha256:9eff7169e3ca452995af0493cc20d35452c4bfd06122c36c06457119ffbd411b", size = 1537351, upload-time = "2026-02-23T12:37:24.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/95/96bd702a260ddcdeef35a1d99a510b1f0cd51eab40f749daa728a2f66728/lap-0.5.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77bbb235de0a416c77aae07aa2bebed4846ed741002da7721059279bd130ed4d", size = 1480314, upload-time = "2026-02-23T12:36:20.979Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c8/c16081ffcc8bf9f123940af8b74bfc8a1fac4f36b3cd7e9b440fdecd9fbc/lap-0.5.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8a793935e238f5430f764c38a1757331e86487738e5c7e8b82c374860e5a1074", size = 1478096, upload-time = "2026-02-23T12:36:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/92/0a/8d8395c8ea22a665ab4150fb2bcb97cc1f987843a1d316aaabc2d71044dd/lap-0.5.13-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:226c24acbc1acd22c76bac54525174577571d7e71e70845d0c43dd664332e867", size = 1732084, upload-time = "2026-02-23T12:36:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/8e/82/63fd09e866677f4263372785b23908efcce8da39bcc72fcced51e606bbe2/lap-0.5.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:355600a369281c830f900a9a215f8a8729c89ce3f2bf75e1943386fe3d8d1c88", size = 1725964, upload-time = "2026-02-23T12:36:25.339Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d3/82678703ab1b5a8773905e982244624b14ad004d8d3068d466c56bde0a31/lap-0.5.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a099030000709e5acfc85b1f3a464a2b7a61abc50e51ab0235f3058d9f26abb", size = 1724642, upload-time = "2026-02-23T12:36:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/89/f9/e1b61bd002ed6d37e71c355e102ca626f5c50218e769d3105215733b6c0d/lap-0.5.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8687037b179a4a5014f69d26ab917fd2129bbe5894b0768e0a18a60e242794da", size = 1735247, upload-time = "2026-02-23T12:36:28.16Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/cfd1b2274c00aba8513c0fa385c7e71790a9f44d7d23f5cdbcd94a895c06/lap-0.5.13-cp312-cp312-win_amd64.whl", hash = "sha256:eb9fc5d7977cb73cc6e69ee704b5329d18d0b1e1da27f4a6c848259b8148f39a", size = 1476908, upload-time = "2026-02-23T12:36:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/9101b3837c3aad5b0ca84f7fcdb8a75ecc666d8f060e7592a97e55f6da57/lap-0.5.13-cp312-cp312-win_arm64.whl", hash = "sha256:0f96f70d093896f0c61c48ad0b31b88225d310e7f6ab50401ca8fe9f5d5268d4", size = 1465883, upload-time = "2026-02-23T12:36:30.832Z" }, + { url = "https://files.pythonhosted.org/packages/84/5b/329c1cdb1fd3a7c9d971310351a1bdfb4264110f9101e2ead942c852a7a0/lap-0.5.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d5e4b4d3b5b7530f28181c7e5dde892d808c19a08a8a8406c505095a272b9849", size = 1479642, upload-time = "2026-02-23T12:36:33.248Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ea/c2b401643c1c0a4e404bc15335302ccd7cddf0f095dbf4b04e911a84ce31/lap-0.5.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a806e4af277199c4161164a4ea9311f217ed0c084ca5fde010743d2ac8ac9ba", size = 1477371, upload-time = "2026-02-23T12:36:34.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5a/ef374f285dbab0673071503688e869354f6c2374be57880b1b997baba161/lap-0.5.13-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:508f6360c7bf2c59d89adff7dba8fd39166573d23f002f54a678c2e026b614cc", size = 1720150, upload-time = "2026-02-23T12:36:35.781Z" }, + { url = "https://files.pythonhosted.org/packages/fa/16/b9316bee1776229baad3dca301daca5acd0cde0523227a4fb8e223b85bf6/lap-0.5.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdd55fb97879ba924821f8386a51bbbe8f1088fc4f4d9cd1afa40635c1b17036", size = 1715566, upload-time = "2026-02-23T12:36:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c3/341bdec28a1aaf99ed874fc48793d154ea9985bb498c8a4fd459cf9424e8/lap-0.5.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b145a738c55d26556a233d1bc597f96e0e00c0d11a1bfca07cf5907c00969126", size = 1705991, upload-time = "2026-02-23T12:36:39.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cc/c9f3c1e82a070d4f64d13a50e326fcc719714f3fe576792dcf3afce625ab/lap-0.5.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab5c634733dd0cdb3ef32f607644d238894df8781bbd91cfbf46435872ad4c92", size = 1716593, upload-time = "2026-02-23T12:36:40.291Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/613db6729e31c945f31d3875d9b00e4b43aff7aa4c53557054bdec29bc95/lap-0.5.13-cp313-cp313-win_amd64.whl", hash = "sha256:1170bd45958733e3ce00ba116fe5e5f1b49b744310d32eca8bf84f71121b7811", size = 1476231, upload-time = "2026-02-23T12:36:41.578Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2b/21503a02c513eb6ae496f65c46ec66a83a87711f7abe7d2c28a431bd099a/lap-0.5.13-cp313-cp313-win_arm64.whl", hash = "sha256:5a94c154fdc3b38c3f0b3ee89ee14b96781f0660aaeababa33d67d2667b4c27e", size = 1465522, upload-time = "2026-02-23T12:36:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ec/0b16230748a32fb7d4b8374b8680e5c453735b88e662f6ea54626ad5bef4/lap-0.5.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c0dfa1df4a6b30d250b9b304add985feb156d62ca3df79cfe1a40e6c80b9d304", size = 1479614, upload-time = "2026-02-23T12:36:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/2d78d0d9cad96b15e37e7195854e32bfc61c9674dea1c5b62092880e89af/lap-0.5.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2bb48c8fd21bb9f69099760cfc90233467e52c62e1528b271f961b4d3b59308", size = 1477530, upload-time = "2026-02-23T12:36:46.016Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0d/da2d6d3c87e09e3d89b83fb4b35717d6608e514b37a299f8f23d4eccafa5/lap-0.5.13-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8a29fcacbd1d94c68e0b36513558213940943d62ae8fd65f55350f7b8be073c0", size = 1716942, upload-time = "2026-02-23T12:36:47.492Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3e/7ebfdbd52f818074c6517779c6da1f25a8e5eced38cb1518e7c2a618d04d/lap-0.5.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f5a7a5f309fa55588eb21ec1bb347356800d4007b0547bb25b5d98552cfaa1", size = 1714853, upload-time = "2026-02-23T12:36:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8e/5fcedfaf18c2db03410e7b6bda191cb81ec1e452b1f38e27f668ad87a33e/lap-0.5.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1ab768edaa10ee9ad32bd651ed904104c5ca12c7333bad8cd43cb62bdb62fd9", size = 1705449, upload-time = "2026-02-23T12:36:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/cd/04/3ba6fd224fe1994bb7fa6f0131cc73f54f5487df69a2dacd4dc02df66f78/lap-0.5.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:398db6cb10287e97c2f54c9f333adbee4a2e502f00744b402173a95749ee35c0", size = 1713766, upload-time = "2026-02-23T12:36:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/32/57/a6b18ec8dbb0145debfa3c8dfa9f35c3e5a28e96340cd8138370d1605657/lap-0.5.13-cp314-cp314-win_amd64.whl", hash = "sha256:40ef084ff5cd10fffbac76f56de4f5c2da039af57412e19a496c263397c8ffb4", size = 1477488, upload-time = "2026-02-23T12:36:53.514Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/a23772ed1ced8d58e089f23d835857b9ae759a9f5733edc4b0c52fb393db/lap-0.5.13-cp314-cp314-win_arm64.whl", hash = "sha256:b5ef3928303c37661f887e1f8b28a381b07bcb0b9868fe911b5ef4e205f31495", size = 1467015, upload-time = "2026-02-23T12:36:54.684Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1243,6 +1278,7 @@ name = "tracking-parking" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "lap" }, { name = "numpy" }, { name = "opencv-python" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, @@ -1255,6 +1291,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "lap", specifier = ">=0.5.13" }, { name = "numpy", specifier = ">=1.24.4,<2.0" }, { name = "opencv-python", specifier = ">=4.8.1" }, { name = "pandas", specifier = ">=2.2.1" }, From b565959e59c24e7838cbe378934d52c4a9086d7b Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:10:19 +0900 Subject: [PATCH 16/57] =?UTF-8?q?feat:=20=E8=BB=8A=E4=B8=A1=E3=82=AF?= =?UTF-8?q?=E3=83=A9=E3=82=B9=E8=A8=AD=E5=AE=9A=E3=82=92Config=E3=81=AB?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81YOLO=E3=83=88=E3=83=A9?= =?UTF-8?q?=E3=83=83=E3=82=AD=E3=83=B3=E3=82=B0=E3=81=AB=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/detection/config.py | 6 ++++++ raspi/line_detection/main.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/raspi/line_detection/detection/config.py b/raspi/line_detection/detection/config.py index 5640fca5..f55e02ce 100644 --- a/raspi/line_detection/detection/config.py +++ b/raspi/line_detection/detection/config.py @@ -31,6 +31,7 @@ class Config: # YOLOモデル設定 model_path: str confidence_threshold: float + vehicle_classes: list # 検出する車両クラスIDのリスト # ライン設定 line1: Line # 入口側ライン(主判定) @@ -79,6 +80,10 @@ def from_env(cls, env_path: str = None): confidence_threshold = float(os.getenv("CONFIDENCE_THRESHOLD", "0.3")) + # 車両クラス設定 + vehicle_classes_str = os.getenv("VEHICLE_CLASSES", "2,3,5,7") + vehicle_classes = [int(x.strip()) for x in vehicle_classes_str.split(",")] + # Line1設定(入口側) line1 = Line( start=( @@ -126,6 +131,7 @@ def from_env(cls, env_path: str = None): home_dir=home_dir, model_path=model_path, confidence_threshold=confidence_threshold, + vehicle_classes=vehicle_classes, line1=line1, line2=line2, parking_ref_point=parking_ref_point, diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index 932a08cd..abb4887c 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -113,11 +113,12 @@ def process_video(video_path: str, config: Config, output_dir: str): if not ret: break - # 2.1 YOLO検知+トラッキング + # 2.1 YOLO検知+トラッキング(車両クラスのみ) results = model.track( frame, persist=True, conf=config.confidence_threshold, + classes=config.vehicle_classes, verbose=False ) From ddb141c06adecfb4aca75893e8c05da9bdf0d4ca Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:20:17 +0900 Subject: [PATCH 17/57] =?UTF-8?q?feat:=20IOU=E9=96=BE=E5=80=A4=E3=82=92Con?= =?UTF-8?q?fig=E3=81=AB=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E4=BA=A4=E5=B7=AE=E6=A4=9C=E7=9F=A5=E3=81=AE?= =?UTF-8?q?=E6=96=B9=E5=90=91=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/detection/config.py | 3 + .../line_detection/detection/line_crossing.py | 10 +- raspi/line_detection/detection/tracker.py | 13 +- raspi/line_detection/main.py | 9 +- .../visualize_lines_and_vehicles.py | 263 ++++++++++++++++++ 5 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 raspi/line_detection/visualize_lines_and_vehicles.py diff --git a/raspi/line_detection/detection/config.py b/raspi/line_detection/detection/config.py index f55e02ce..53f5c92e 100644 --- a/raspi/line_detection/detection/config.py +++ b/raspi/line_detection/detection/config.py @@ -42,6 +42,7 @@ class Config: margin: float # ライン交差判定のマージン max_frame_gap: int # Line1とLine2の最大フレーム差 cleanup_threshold: int # 古い追跡をクリーンアップするフレーム数 + iou_threshold: float # トラッキングのIOU閾値 # 処理方式 method: str # "hybrid" 固定 @@ -118,6 +119,7 @@ def from_env(cls, env_path: str = None): margin = float(os.getenv("MARGIN", "1000.0")) max_frame_gap = int(os.getenv("MAX_FRAME_GAP", "90")) cleanup_threshold = int(os.getenv("CLEANUP_THRESHOLD", "150")) + iou_threshold = float(os.getenv("IOU_THRESHOLD", "0.3")) # 処理方式 method = os.getenv("METHOD", "hybrid") @@ -138,6 +140,7 @@ def from_env(cls, env_path: str = None): margin=margin, max_frame_gap=max_frame_gap, cleanup_threshold=cleanup_threshold, + iou_threshold=iou_threshold, method=method, save_video=save_video, save_logs=save_logs, diff --git a/raspi/line_detection/detection/line_crossing.py b/raspi/line_detection/detection/line_crossing.py index 555b1e57..cdb90eda 100644 --- a/raspi/line_detection/detection/line_crossing.py +++ b/raspi/line_detection/detection/line_crossing.py @@ -102,7 +102,7 @@ def detect_line1_crossing(self, def detect_line2_crossing(self, prev_point: Tuple[float, float], - curr_point: Tuple[float, float]) -> bool: + curr_point: Tuple[float, float]) -> Optional[str]: """ Line2(駐車場側)の交差を検知 @@ -111,15 +111,17 @@ def detect_line2_crossing(self, curr_point: 現フレームの車両位置 Returns: - bool: 交差した場合True、そうでない場合False + Optional[str]: + "IN": 入庫方向に交差 + "OUT": 出庫方向に交差 + None: 交差なし """ - result = self._detect_crossing( + return self._detect_crossing( prev_point, curr_point, self.line2, self.parking_side_line2 ) - return result is not None def _detect_crossing(self, prev_point: Tuple[float, float], diff --git a/raspi/line_detection/detection/tracker.py b/raspi/line_detection/detection/tracker.py index c99b2f40..195b4a38 100644 --- a/raspi/line_detection/detection/tracker.py +++ b/raspi/line_detection/detection/tracker.py @@ -22,7 +22,7 @@ class VehicleState: line1_timestamp: Optional[float] = None # Line2状態(補助ライン) - line2_passed: bool = False + line2_direction: Optional[str] = None # "IN" or "OUT" line2_frame: Optional[int] = None line2_timestamp: Optional[float] = None @@ -59,14 +59,15 @@ def record_line1_crossing(self, direction: str, frame_id: int): self.line1_timestamp = time.time() self.passed_order.append("line1") - def record_line2_crossing(self, frame_id: int): + def record_line2_crossing(self, direction: str, frame_id: int): """ Line2交差を記録 Args: + direction: "IN" or "OUT" frame_id: フレーム番号 """ - self.line2_passed = True + self.line2_direction = direction self.line2_frame = frame_id self.line2_timestamp = time.time() self.passed_order.append("line2") @@ -86,7 +87,11 @@ def calculate_confidence(self, max_frame_gap: int) -> Optional[str]: return None # Line2を交差していない場合はnormal - if not self.line2_passed: + if not self.line2_direction: + return "normal" + + # Line1とLine2の方向が一致しているかチェック + if self.line1_direction != self.line2_direction: return "normal" # フレーム差をチェック diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index abb4887c..da5cf1a5 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -118,6 +118,7 @@ def process_video(video_path: str, config: Config, output_dir: str): frame, persist=True, conf=config.confidence_threshold, + iou=config.iou_threshold, classes=config.vehicle_classes, verbose=False ) @@ -143,7 +144,7 @@ def process_video(video_path: str, config: Config, output_dir: str): ) # Line2交差検知 - line2_crossed = detector.detect_line2_crossing( + line2_dir = detector.detect_line2_crossing( state.prev_point, state.curr_point ) @@ -153,8 +154,8 @@ def process_video(video_path: str, config: Config, output_dir: str): state.record_line1_crossing(line1_dir, frame_id) # Line2交差を記録 - if line2_crossed: - state.record_line2_crossing(frame_id) + if line2_dir: + state.record_line2_crossing(line2_dir, frame_id) # ハイブリッド方式でイベント判定 if tracker.should_count_event(state): @@ -168,7 +169,7 @@ def process_video(video_path: str, config: Config, output_dir: str): frame_id=frame_id, fps=fps, confidence=state.confidence, - line2_crossed=state.line2_passed + line2_crossed=(state.line2_direction is not None) ) print(f"[Frame {frame_id}] ID:{track_id} {event_type} (信頼度: {state.confidence})") diff --git a/raspi/line_detection/visualize_lines_and_vehicles.py b/raspi/line_detection/visualize_lines_and_vehicles.py new file mode 100644 index 00000000..5d1c51f9 --- /dev/null +++ b/raspi/line_detection/visualize_lines_and_vehicles.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +ライン位置と車両検出位置を可視化 +車両代表点がラインのどの位置を通過しているかを確認 +""" + +import cv2 +import sys +import os +from ultralytics import YOLO + +sys.path.insert(0, os.path.dirname(__file__)) + +from detection.config import Config +from detection.line_crossing import get_vehicle_point, side_of_line + + +def visualize(video_path: str, config: Config, output_path: str = None, start_frame: int = 0, end_frame: int = 300): + """ + ライン位置と車両検出を可視化 + + Args: + video_path: 動画ファイルのパス + config: 設定 + output_path: 出力動画パス(Noneの場合は保存しない) + start_frame: 開始フレーム + end_frame: 終了フレーム + """ + print("\n" + "=" * 80) + print("ライン・車両可視化") + print("=" * 80) + print(f"動画: {video_path}") + print(f"フレーム範囲: {start_frame} - {end_frame}") + print(f"Line1: {config.line1.start} → {config.line1.end}") + print(f"Line2: {config.line2.start} → {config.line2.end}") + print(f"駐車場基準点: {config.parking_ref_point}") + print(f"MARGIN: {config.margin}") + print("=" * 80 + "\n") + + # YOLOモデルをロード + model = YOLO(config.model_path) + + # 動画キャプチャを開く + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + print(f"エラー: 動画を開けません: {video_path}") + return + + 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)) + + # 出力動画 + out = None + if output_path: + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) + print(f"出力動画: {output_path}\n") + + # 駐車場側の符号を事前計算 + parking_side_line1 = side_of_line( + config.parking_ref_point, + config.line1.start, + config.line1.end + ) + parking_side_line2 = side_of_line( + config.parking_ref_point, + config.line2.start, + config.line2.end + ) + + # 開始フレームまでスキップ + if start_frame > 0: + cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) + print(f"フレーム {start_frame} までスキップ中...\n") + + frame_id = start_frame + vehicle_points_history = {} # track_id -> [(x, y), ...] + + while cap.isOpened() and frame_id < end_frame: + ret, frame = cap.read() + if not ret: + break + + # フレームをコピー + vis_frame = frame.copy() + + # YOLO検知+トラッキング + results = model.track( + frame, + persist=True, + conf=config.confidence_threshold, + iou=config.iou_threshold, + classes=config.vehicle_classes, + verbose=False + ) + + # Line1を描画(緑) + cv2.line( + vis_frame, + tuple(map(int, config.line1.start)), + tuple(map(int, config.line1.end)), + (0, 255, 0), + 3 + ) + + # Line2を描画(黄色) + cv2.line( + vis_frame, + tuple(map(int, config.line2.start)), + tuple(map(int, config.line2.end)), + (0, 255, 255), + 3 + ) + + # 駐車場基準点を描画(マゼンタ) + cv2.circle( + vis_frame, + tuple(map(int, config.parking_ref_point)), + 10, + (255, 0, 255), + -1 + ) + + # 車両検出 + if results[0].boxes.id is not None: + track_ids = results[0].boxes.id.int().tolist() + bboxes = results[0].boxes.xyxy.tolist() + + for track_id, bbox in zip(track_ids, bboxes): + # 車両代表点(底面中央) + vehicle_point = get_vehicle_point(bbox) + + # 履歴に追加 + if track_id not in vehicle_points_history: + vehicle_points_history[track_id] = [] + vehicle_points_history[track_id].append(vehicle_point) + + # バウンディングボックスを描画(青) + x1, y1, x2, y2 = map(int, bbox) + cv2.rectangle(vis_frame, (x1, y1), (x2, y2), (255, 0, 0), 2) + + # 車両代表点を描画(赤、大きめ) + cv2.circle( + vis_frame, + tuple(map(int, vehicle_point)), + 8, + (0, 0, 255), + -1 + ) + + # Line1との外積を計算 + side1 = side_of_line(vehicle_point, config.line1.start, config.line1.end) + side2 = side_of_line(vehicle_point, config.line2.start, config.line2.end) + + # 外積値とMARGIN比較を表示 + status1 = "OK" if abs(side1) >= config.margin else f"小({abs(side1):.0f}<{config.margin})" + status2 = "OK" if abs(side2) >= config.margin else f"小({abs(side2):.0f}<{config.margin})" + + # 駐車場側かどうか + is_parking_side1 = "駐車場側" if side1 * parking_side_line1 > 0 else "入口側" + is_parking_side2 = "駐車場側" if side2 * parking_side_line2 > 0 else "入口側" + + # 情報を表示 + info_text = [ + f"ID:{track_id}", + f"L1:{side1:.0f} [{status1}] {is_parking_side1}", + f"L2:{side2:.0f} [{status2}] {is_parking_side2}" + ] + + y_offset = int(vehicle_point[1]) - 20 + for i, text in enumerate(info_text): + cv2.putText( + vis_frame, + text, + (int(vehicle_point[0]) + 15, y_offset - i * 20), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (0, 0, 255), + 2 + ) + + # 軌跡を描画(最新30点) + if len(vehicle_points_history[track_id]) > 1: + points = vehicle_points_history[track_id][-30:] + for i in range(len(points) - 1): + cv2.line( + vis_frame, + tuple(map(int, points[i])), + tuple(map(int, points[i + 1])), + (255, 0, 255), + 2 + ) + + # フレーム情報を表示 + cv2.putText( + vis_frame, + f"Frame: {frame_id}", + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 1.0, + (255, 255, 255), + 2 + ) + + # 凡例を表示 + legend_y = 60 + legends = [ + ("Line1 (緑): 入口側ライン", (0, 255, 0)), + ("Line2 (黄): 駐車場側ライン", (0, 255, 255)), + ("赤点: 車両代表点", (0, 0, 255)), + ("マゼンタ: 駐車場基準点", (255, 0, 255)) + ] + for text, color in legends: + cv2.putText(vis_frame, text, (10, legend_y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2) + legend_y += 25 + + # 保存 + if out: + out.write(vis_frame) + + # 表示 + cv2.imshow("Visualization", vis_frame) + if cv2.waitKey(1) & 0xFF == ord('q'): + break + + if frame_id % 30 == 0: + print(f"処理中... {frame_id}/{end_frame}") + + frame_id += 1 + + cap.release() + if out: + out.release() + cv2.destroyAllWindows() + + print(f"\n✓ 完了: {frame_id}フレーム処理") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("使用方法: python visualize_lines_and_vehicles.py <動画パス> [出力動画パス] [開始フレーム] [終了フレーム]") + print("\n例:") + print(" python visualize_lines_and_vehicles.py data/inputs/IMG_2787.mp4") + print(" python visualize_lines_and_vehicles.py data/inputs/IMG_2787.mp4 debug_vis.mp4") + print(" python visualize_lines_and_vehicles.py data/inputs/IMG_2787.mp4 debug_vis.mp4 300 600") + sys.exit(1) + + video_path = sys.argv[1] + output_path = sys.argv[2] if len(sys.argv) > 2 else None + start_frame = int(sys.argv[3]) if len(sys.argv) > 3 else 0 + end_frame = int(sys.argv[4]) if len(sys.argv) > 4 else 300 + + # 設定を読み込み + env_path = os.path.join(os.path.dirname(__file__), ".env") + if not os.path.exists(env_path): + print(f"エラー: .envファイルが見つかりません: {env_path}") + sys.exit(1) + + config = Config.from_env(env_path) + config.validate() + + visualize(video_path, config, output_path, start_frame, end_frame) From d33b0fe9210a1a1bc10e940c49df29bf21266b63 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 26 Jun 2026 05:58:53 +0900 Subject: [PATCH 18/57] =?UTF-8?q?fix:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=A4=9C=E7=9F=A5=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=81=AE?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AB?= =?UTF-8?q?data=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 76ec6e8a..d9ddc285 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ raspi/result/prediction_visual.png .venv/ # 2ライン検知システムの出力ファイル -raspi/line_detection/data/* \ No newline at end of file +raspi/line_detection/data/* +data/* \ No newline at end of file From 4df047f474f84ead825a2f9b8b7d36d7d9b7c289 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:13:31 +0900 Subject: [PATCH 19/57] =?UTF-8?q?fix:=20.gitignore=E3=81=ABdata=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=AB=E3=83=80=E3=81=A8docs=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=AB=E3=83=80=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d9ddc285..2be3fe18 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,7 @@ raspi/result/prediction_visual.png # 2ライン検知システムの出力ファイル raspi/line_detection/data/* -data/* \ No newline at end of file +data/* + +# ドキュメント +docs/ \ No newline at end of file From dc798c6819c5de35ca153cf48ac66b34953859e6 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:04:02 +0900 Subject: [PATCH 20/57] =?UTF-8?q?add:=20common/frame=5Fstats.py=20-=20?= =?UTF-8?q?=E3=83=95=E3=83=AC=E3=83=BC=E3=83=A0=E5=87=A6=E7=90=86=E6=99=82?= =?UTF-8?q?=E9=96=93=E7=B5=B1=E8=A8=88=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- raspi/common/__init__.py | 0 raspi/common/frame_stats.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 raspi/common/__init__.py create mode 100644 raspi/common/frame_stats.py diff --git a/raspi/common/__init__.py b/raspi/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/raspi/common/frame_stats.py b/raspi/common/frame_stats.py new file mode 100644 index 00000000..899b1941 --- /dev/null +++ b/raspi/common/frame_stats.py @@ -0,0 +1,56 @@ +""" +フレーム処理時間の統計ユーティリティ(両検出ロジック共通) + +W&B の summary に入れる速度指標(p95 / effective_fps / realtime_ok 等)を +1 フレームごとの処理時間リストから算出する。 +""" + +from typing import List, Dict + +import numpy as np + + +def compute_frame_stats(frame_ms_list: List[float], source_fps: float) -> Dict: + """ + フレーム処理時間リストから速度統計を算出する。 + + Args: + frame_ms_list: 1 フレームあたりの処理時間(ミリ秒)のリスト + source_fps: 入力動画の FPS(realtime 判定の基準) + + Returns: + dict: 速度統計。空リスト時は全て 0 / realtime_ok=False を返し例外を出さない。 + - frame_ms_min / frame_ms_max / frame_ms_mean + - frame_ms_p50 / frame_ms_p95 / frame_ms_p99 + - total_ms + - effective_fps (= 1000 / frame_ms_mean) + - realtime_ok (= effective_fps >= source_fps) + """ + if not frame_ms_list: + return { + "frame_ms_min": 0.0, + "frame_ms_max": 0.0, + "frame_ms_mean": 0.0, + "frame_ms_p50": 0.0, + "frame_ms_p95": 0.0, + "frame_ms_p99": 0.0, + "total_ms": 0.0, + "effective_fps": 0.0, + "realtime_ok": False, + } + + arr = np.asarray(frame_ms_list, dtype=float) + mean = float(arr.mean()) + effective_fps = 1000.0 / mean if mean > 0 else 0.0 + + return { + "frame_ms_min": float(arr.min()), + "frame_ms_max": float(arr.max()), + "frame_ms_mean": mean, + "frame_ms_p50": float(np.percentile(arr, 50)), + "frame_ms_p95": float(np.percentile(arr, 95)), + "frame_ms_p99": float(np.percentile(arr, 99)), + "total_ms": float(arr.sum()), + "effective_fps": effective_fps, + "realtime_ok": bool(effective_fps >= source_fps) if source_fps > 0 else False, + } From 0c59b55a638a16c4c7fea3b47d966c5a66dabbac Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:05:11 +0900 Subject: [PATCH 21/57] =?UTF-8?q?add:=20common/wandb=5Flogger.py=20-=20W&B?= =?UTF-8?q?=E5=AE=9F=E9=A8=93=E3=83=AD=E3=82=AC=E3=83=BC=E3=81=A8exp=5Fkey?= =?UTF-8?q?=E7=94=9F=E6=88=90=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- raspi/common/wandb_logger.py | 173 +++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 raspi/common/wandb_logger.py diff --git a/raspi/common/wandb_logger.py b/raspi/common/wandb_logger.py new file mode 100644 index 00000000..618afe55 --- /dev/null +++ b/raspi/common/wandb_logger.py @@ -0,0 +1,173 @@ +""" +Weights & Biases 連携の薄いラッパ(両検出ロジック共通) + +- W&B 無効時(enabled=False)は wandb を一切 import せず、全メソッドが no-op。 + → wandb 未インストール環境・CI・オフライン実行で既存挙動と完全一致する。 +- WANDB_MODE(online/offline)は wandb 側の標準挙動に委譲(本ラッパで上書きしない)。 + オフライン計測 → 後日 `wandb sync ` でアップロードできる。 +- `build_exp_key()` は両ロジック・後追い評価スクリプトが必ず共有する突合キー生成関数。 +""" + +import json +from pathlib import Path +from typing import Optional, List, Dict, Any + + +# 後追い(SAM3 GT 突合後)で埋める精度系 summary キー。今は None で確保する。 +ACCURACY_PLACEHOLDER_KEYS = [ + "accuracy", + "precision", + "recall", + "f1", + "tp", + "fp", + "fn", + "tn", + "eval_unit", + "gt_source", +] + + +def build_exp_key(logic_name: str, dataset: str, device_name: str, params: Dict[str, Any]) -> str: + """ + run を一意に再特定するための複合キーを生成する(全スクリプト共通)。 + + 形式: f"{logic_name}__{dataset}__{param_str}__{device_name}" + param_str は params をキー名の昇順ソートで "k1=v1_k2=v2" に正規化する + (float は f"{v:g}")。 + + 各スクリプトで独自フォーマットを組み立ててはならない(突合キーとして機能しなくなるため)。 + """ + parts = [] + for key in sorted(params.keys()): + value = params[key] + if isinstance(value, float): + value_str = f"{value:g}" + else: + value_str = str(value) + parts.append(f"{key}={value_str}") + param_str = "_".join(parts) + return f"{logic_name}__{dataset}__{param_str}__{device_name}" + + +class ExperimentLogger: + """W&B 連携ラッパ。enabled=False で完全 no-op になる。""" + + def __init__( + self, + project: str, + config: Dict[str, Any], + group: str, + job_type: str, + tags: List[str], + enabled: bool, + ): + """ + Args: + project: W&B プロジェクト名 + config: run 開始時に固定するスカラ設定(exp_key を含む) + group: run のグループ(= logic_name) + job_type: "speed_eval" / "accuracy_eval" 等 + tags: device_name, input_type 等 + enabled: False のとき wandb.init を呼ばず全メソッドを no-op にする + """ + self.enabled = enabled + self._wandb = None + self._run = None + self._exp_key = config.get("exp_key") if config else None + + if not self.enabled: + return + + # 遅延 import: enabled のときだけ読み込む(未インストール環境で落ちないように)。 + import wandb + + self._wandb = wandb + self._run = wandb.init( + project=project, + config=config, + group=group, + job_type=job_type, + tags=tags, + ) + + @property + def run_id(self) -> Optional[str]: + """wandb.run.id(無効時は None)。""" + if not self.enabled or self._run is None: + return None + return self._run.id + + def define_metric(self, name: str, step_metric: str) -> None: + """時系列メトリクスの x 軸を step_metric(例: t_rel_sec)に宣言する。""" + if not self.enabled: + return + self._wandb.define_metric(name, step_metric=step_metric) + + def log_frame(self, step: int, metrics: Dict[str, Any]) -> None: + """時系列メトリクスを log する(step は単調増加の実フレーム番号)。""" + if not self.enabled: + return + self._wandb.log(metrics, step=step) + + def set_summary(self, key: str, value: Any) -> None: + """summary に単一スカラを設定する。""" + if not self.enabled: + return + self._run.summary[key] = value + + def set_summaries(self, d: Dict[str, Any]) -> None: + """summary に複数スカラをまとめて設定する。""" + if not self.enabled: + return + for key, value in d.items(): + self._run.summary[key] = value + + def update_running_summary(self, d: Dict[str, Any]) -> None: + """処理途中の集計(count_in/out 等)を summary へ随時反映する。 + + 長時間運用でクラッシュしても直近の集計が残るようにするための薄いメソッド。 + """ + self.set_summaries(d) + + def init_accuracy_placeholders(self) -> None: + """精度系 summary キーを None で確保する(init 直後に呼ぶ)。 + + 後追いスクリプトが同じスキーマへ書き込めるようにするため。 + """ + if not self.enabled: + return + for key in ACCURACY_PLACEHOLDER_KEYS: + self._run.summary[key] = None + + def finish(self, exit_code: int = 0) -> None: + """run を finish する。異常終了時は exit_code=1 で呼ぶ(try/finally 必須)。""" + if not self.enabled or self._run is None: + return + self._wandb.finish(exit_code=exit_code) + + def save_run_id(self, out_dir: Path) -> None: + """out_dir/"wandb_run_id.txt" に run.id と exp_key を書き出す。""" + if not self.enabled: + return + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + text = f"wandb_run_id={self.run_id}\nexp_key={self._exp_key}\n" + (out_dir / "wandb_run_id.txt").write_text(text, encoding="utf-8") + + def append_to_result_json(self, result_path: Path) -> None: + """既存の result.json へ wandb_run_id / exp_key を追記する。 + + ファイルが無い / 無効時は何もしない(後方互換)。 + """ + if not self.enabled: + return + result_path = Path(result_path) + if not result_path.exists(): + return + data = json.loads(result_path.read_text(encoding="utf-8")) + data["wandb_run_id"] = self.run_id + data["exp_key"] = self._exp_key + result_path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) From 3e6cced820806b232e2e48dba8ce053f2e6799fc Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:06:40 +0900 Subject: [PATCH 22/57] =?UTF-8?q?add:=20common/tests=20-=20frame=5Fstats?= =?UTF-8?q?=E3=81=A8ExperimentLogger=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- raspi/common/tests/__init__.py | 0 raspi/common/tests/test_frame_stats.py | 41 +++++++++++++++++++++ raspi/common/tests/test_wandb_logger.py | 48 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 raspi/common/tests/__init__.py create mode 100644 raspi/common/tests/test_frame_stats.py create mode 100644 raspi/common/tests/test_wandb_logger.py diff --git a/raspi/common/tests/__init__.py b/raspi/common/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/raspi/common/tests/test_frame_stats.py b/raspi/common/tests/test_frame_stats.py new file mode 100644 index 00000000..e59746bc --- /dev/null +++ b/raspi/common/tests/test_frame_stats.py @@ -0,0 +1,41 @@ +import numpy as np + +from common.frame_stats import compute_frame_stats + + +def test_basic_stats(): + frame_ms = [10.0, 20.0, 30.0, 40.0, 50.0] + stats = compute_frame_stats(frame_ms, source_fps=30.0) + + assert stats["frame_ms_min"] == 10.0 + assert stats["frame_ms_max"] == 50.0 + assert stats["frame_ms_mean"] == 30.0 + assert stats["frame_ms_p50"] == 30.0 + assert stats["frame_ms_p95"] == np.percentile(frame_ms, 95) + assert stats["frame_ms_p99"] == np.percentile(frame_ms, 99) + assert stats["total_ms"] == 150.0 + # 1000 / 30ms = 33.33 fps >= 30fps + assert stats["effective_fps"] == 1000.0 / 30.0 + assert stats["realtime_ok"] is True + + +def test_realtime_not_ok(): + # 平均 100ms → 10fps < 30fps + stats = compute_frame_stats([100.0, 100.0], source_fps=30.0) + assert stats["effective_fps"] == 10.0 + assert stats["realtime_ok"] is False + + +def test_empty_list_no_exception(): + stats = compute_frame_stats([], source_fps=30.0) + assert stats["frame_ms_mean"] == 0.0 + assert stats["frame_ms_p95"] == 0.0 + assert stats["total_ms"] == 0.0 + assert stats["effective_fps"] == 0.0 + assert stats["realtime_ok"] is False + + +def test_zero_source_fps(): + # source_fps=0 でも例外を出さず realtime_ok=False + stats = compute_frame_stats([10.0, 20.0], source_fps=0.0) + assert stats["realtime_ok"] is False diff --git a/raspi/common/tests/test_wandb_logger.py b/raspi/common/tests/test_wandb_logger.py new file mode 100644 index 00000000..b27db846 --- /dev/null +++ b/raspi/common/tests/test_wandb_logger.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from common.wandb_logger import build_exp_key, ExperimentLogger + + +def test_build_exp_key_sorts_keys(): + # キーは昇順ソートされる(辞書の挿入順に依存しない) + key = build_exp_key( + "roi_counter", "IMG_2788", "raspi5", {"s_low": 0.25, "s_high": 0.75} + ) + assert key == "roi_counter__IMG_2788__s_high=0.75_s_low=0.25__raspi5" + + +def test_build_exp_key_float_format(): + # float は f"{v:g}"(末尾ゼロが落ちる) + key = build_exp_key("l", "d", "dev", {"a": 0.100, "b": 1.0}) + assert key == "l__d__a=0.1_b=1__dev" + + +def test_build_exp_key_insertion_order_independent(): + k1 = build_exp_key("l", "d", "dev", {"a": 1, "b": 2}) + k2 = build_exp_key("l", "d", "dev", {"b": 2, "a": 1}) + assert k1 == k2 + + +def test_disabled_logger_is_noop(tmp_path: Path): + lg = ExperimentLogger( + project="p", + config={"exp_key": "k"}, + group="g", + job_type="speed_eval", + tags=["t"], + enabled=False, + ) + # 全メソッドが例外を出さず no-op(wandb を import しない) + assert lg.run_id is None + lg.define_metric("current_parked", step_metric="t_rel_sec") + lg.log_frame(0, {"x": 1}) + lg.set_summary("a", 1) + lg.set_summaries({"b": 2}) + lg.update_running_summary({"c": 3}) + lg.init_accuracy_placeholders() + lg.save_run_id(tmp_path) + lg.append_to_result_json(tmp_path / "result.json") + lg.finish(0) + + # 無効時はファイルを書かない + assert not (tmp_path / "wandb_run_id.txt").exists() From 73ad351cea26b3e4732f10f352f344228051e9ce Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:47:42 +0900 Subject: [PATCH 23/57] =?UTF-8?q?fix:=20common/wandb=5Flogger.py=20-=20LOG?= =?UTF-8?q?=5FINTERVAL=5FSEC=E6=A4=9C=E8=A8=BC=E3=81=A8=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E5=A2=83=E7=95=8C=E8=A8=88=E7=AE=97=E3=83=98=E3=83=AB=E3=83=91?= =?UTF-8?q?=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/common/tests/test_wandb_logger.py | 77 ++++++++++++++++++++++++- raspi/common/wandb_logger.py | 31 ++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/raspi/common/tests/test_wandb_logger.py b/raspi/common/tests/test_wandb_logger.py index b27db846..e140a02b 100644 --- a/raspi/common/tests/test_wandb_logger.py +++ b/raspi/common/tests/test_wandb_logger.py @@ -1,6 +1,14 @@ from pathlib import Path -from common.wandb_logger import build_exp_key, ExperimentLogger +import pytest + +from common.wandb_logger import ( + build_exp_key, + ExperimentLogger, + next_log_boundary, + should_log_frame, + validate_log_interval_sec, +) def test_build_exp_key_sorts_keys(): @@ -34,7 +42,7 @@ def test_disabled_logger_is_noop(tmp_path: Path): ) # 全メソッドが例外を出さず no-op(wandb を import しない) assert lg.run_id is None - lg.define_metric("current_parked", step_metric="t_rel_sec") + lg.define_metric("net_flow", step_metric="t_rel_sec") lg.log_frame(0, {"x": 1}) lg.set_summary("a", 1) lg.set_summaries({"b": 2}) @@ -46,3 +54,68 @@ def test_disabled_logger_is_noop(tmp_path: Path): # 無効時はファイルを書かない assert not (tmp_path / "wandb_run_id.txt").exists() + + +def test_validate_log_interval_sec_accepts_positive(): + assert validate_log_interval_sec("5") == 5.0 + assert validate_log_interval_sec(0.1) == 0.1 + + +def test_validate_log_interval_sec_rejects_zero(): + with pytest.raises(ValueError): + validate_log_interval_sec(0) + + +def test_validate_log_interval_sec_rejects_negative(): + with pytest.raises(ValueError): + validate_log_interval_sec(-5) + + +def test_validate_log_interval_sec_rejects_nan(): + with pytest.raises(ValueError): + validate_log_interval_sec(float("nan")) + + +def test_validate_log_interval_sec_rejects_inf(): + with pytest.raises(ValueError): + validate_log_interval_sec(float("inf")) + with pytest.raises(ValueError): + validate_log_interval_sec(float("-inf")) + + +def test_validate_log_interval_sec_rejects_bad_string(): + with pytest.raises(ValueError): + validate_log_interval_sec("abc") + + +def test_next_log_boundary_advances_one_interval(): + # next_log_sec == t_rel_sec(等号境界)でも1区間進める(旧 `<=` 意味論を維持) + assert next_log_boundary(0.0, 0.0, 5.0) == 5.0 + + +def test_next_log_boundary_advances_within_interval(): + assert next_log_boundary(0.0, 3.0, 5.0) == 5.0 + + +def test_next_log_boundary_skips_multiple_intervals_no_loop(): + # 複数区間分ジャンプしても O(1) で正しい次境界に到達する + assert next_log_boundary(0.0, 23.0, 5.0) == 25.0 + + +def test_should_log_frame_true_on_boundary_reached(): + assert should_log_frame(5.0, 5.0, False) is True + assert should_log_frame(4.9, 5.0, False) is False + + +def test_should_log_frame_true_on_count_changed_before_boundary(): + assert should_log_frame(2.0, 5.0, True) is True + + +def test_count_changed_log_does_not_disturb_boundary_math(): + # count_changed 起因の早期ログ後も、次の境界計算は絶対時刻(t_rel_sec)のみに基づく + # ため、定期サンプリングのスケジュールがずれない。 + next_log_sec = 0.0 + interval = 5.0 + # count_changed によって t_rel_sec=2.0 で早期ログが発生したとする + next_log_sec = next_log_boundary(next_log_sec, 2.0, interval) + assert next_log_sec == 5.0 diff --git a/raspi/common/wandb_logger.py b/raspi/common/wandb_logger.py index 618afe55..b0b16dc0 100644 --- a/raspi/common/wandb_logger.py +++ b/raspi/common/wandb_logger.py @@ -9,6 +9,7 @@ """ import json +import math from pathlib import Path from typing import Optional, List, Dict, Any @@ -50,6 +51,36 @@ def build_exp_key(logic_name: str, dataset: str, device_name: str, params: Dict[ return f"{logic_name}__{dataset}__{param_str}__{device_name}" +def validate_log_interval_sec(raw_value: Any, *, source: str = "LOG_INTERVAL_SEC") -> float: + """LOG_INTERVAL_SEC の生値(文字列 or 数値)をパース・検証する。 + + 0・負数・NaN・inf・数値変換不能な文字列をすべて ValueError で明示的に拒否する。 + """ + try: + value = float(raw_value) + except (TypeError, ValueError): + raise ValueError(f"{source} must be a positive finite number, got {raw_value!r}") + if math.isnan(value) or math.isinf(value) or value <= 0: + raise ValueError(f"{source} must be a positive finite number, got {raw_value!r}") + return value + + +def next_log_boundary(next_log_sec: float, t_rel_sec: float, log_interval_sec: float) -> float: + """次の定期サンプリング境界を while ループなし(O(1))で計算する。 + + t_rel_sec == next_log_sec の場合も「ログ済み」として1区間進める(旧 `<=` 意味論を維持)。 + count_changed による早期ログでも、この関数は t_rel_sec のみから境界を計算するため、 + 定期的なサンプリング間隔がずれることはない。 + """ + steps = math.floor((t_rel_sec - next_log_sec) / log_interval_sec) + 1 + return next_log_sec + steps * log_interval_sec + + +def should_log_frame(t_rel_sec: float, next_log_sec: float, count_changed: bool) -> bool: + """このフレームで log_frame すべきか判定する(副作用なし)。""" + return t_rel_sec >= next_log_sec or count_changed + + class ExperimentLogger: """W&B 連携ラッパ。enabled=False で完全 no-op になる。""" From a1a773ad71b496f09f3fc093e453b5abc9e57c63 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:47:51 +0900 Subject: [PATCH 24/57] =?UTF-8?q?fix:=20.gitignore=20-=20wandb=5Fintegrati?= =?UTF-8?q?on=5Fspec=5Fv2.md=E3=82=92Git=E7=AE=A1=E7=90=86=E5=AF=BE?= =?UTF-8?q?=E8=B1=A1=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2be3fe18..a88af6fc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ raspi/line_detection/data/* data/* # ドキュメント -docs/ \ No newline at end of file +docs/ +docs/* +!raspi/line_detection/docs/ +!raspi/line_detection/docs/wandb_integration_spec_v2.md From a4a7b1fb7ef8a56e8a87a0cacd5cbe49819b462c Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:47:54 +0900 Subject: [PATCH 25/57] =?UTF-8?q?fix:=20wandb=5Fintegration=5Fspec=5Fv2.md?= =?UTF-8?q?=20-=20=E5=AE=9F=E8=A3=85=E3=81=A8=E3=81=AE=E5=B7=AE=E5=88=86?= =?UTF-8?q?=E3=82=92=E8=A7=A3=E6=B6=88=E3=81=97=E4=BB=95=E6=A7=98=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/wandb_integration_spec_v2.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 raspi/line_detection/docs/wandb_integration_spec_v2.md diff --git a/raspi/line_detection/docs/wandb_integration_spec_v2.md b/raspi/line_detection/docs/wandb_integration_spec_v2.md new file mode 100644 index 00000000..eda6fef2 --- /dev/null +++ b/raspi/line_detection/docs/wandb_integration_spec_v2.md @@ -0,0 +1,287 @@ +# 実験管理機能(Weights & Biases 連携)実装指示書 v2 + +> v1 からの主な変更: オフラインモード対応(§1-8, §6)、スイープ時のトラッカー状態リセット(§4.2)、 +> フレームログのダウンサンプリング(§2.3)、異常終了時の finish 保証(§3.1)、`exp_key` 生成規則の一元化(§3.1)、 +> 共通モジュールの import 経路指定(§3.3)、W&B Artifact のオプション項目追加(§10)。 +> +> このドキュメントは git 管理下の正式な契約書である(Issue #102 にて .gitignore の docs/ 一括除外に例外を追加し追跡開始)。 +> 実装との乖離が見つかった場合は、実装かこのドキュメントのどちらかを速やかに追随させること。 + +## 0. このドキュメントの目的 + +`raspi/` 配下の駐車場入出庫カウントシステムに、Weights & Biases(以下 W&B)による実験管理機能を追加する。 +本システムには検出ロジックが 2 系統あり、両者の **性能比較** と、本番デバイス上での **処理速度管理** を目的とする。 + +- `line_detection/` … 2 ライン + 外積法 + ハイブリッド方式(信頼度付き) +- `roi-counter/` … ROI 内の進行度 `s` による状態機械 + +精度指標(Accuracy / Precision / Recall / F1)は **SAM3 による GT を用いて後日別スクリプトで算出する**ため、本実装では「速度・台数の即時記録」と「精度を後から同一 run へ追記できる仕組み」を分けて作る。 + +--- + +## 1. 設計の大原則(必ず守ること) + +1. **記録粒度を 3 層に分ける。** + - `config`(run 開始時に固定するスカラ設定) + - `summary`(run 終了時の単一スカラ結果) + - `time-series`(フレーム / イベントごとの `wandb.log(step=...)`) +2. **比較したくなりそうな軸は、値が未定でも config キーとして必ず用意する**(未使用なら既定値か `None`)。config に無い軸は後から比較できない。 +3. **精度系の summary キーは、データが無くても `None` で先に確保する**。後追いスクリプトが同じスキーマへ書き込めるようにするため。 + - 注意: W&B のテーブル UI では値が全 run で `None` の間は列が表示されないことがあるが、キー自体は保存されるので問題ない。後追い書き込み後に列が現れる。 +4. **run を一意に再特定できる手段を 2 重で持たせる。** + - `run.id` を出力ディレクトリ内のファイルに保存する。 + - `config` に複合キー `exp_key`(生成規則は §3.1 で一元化)を必ず埋め、run.id を取りこぼしても突合できるようにする。 +5. **時系列メトリクスは後から遡及追加できない**。最初に `wandb.log` する辞書へ余分めにキーを用意しておく。スカラ(summary / config)は後付け可。 +6. 既存スクリプトの**コア処理ロジック(検出・カウント・MAE 算出)は原則変更しない**。W&B 連携は addon として差し込む。 + - **唯一の例外**: §4.2 のトラッカー状態リセット。これは実験の独立性(run 比較の妥当性)に直結するため修正を許可する。 +7. **W&B を無効化できるフラグ**(`USE_WANDB` / 環境変数)を用意し、オフ時は既存挙動と完全に一致させる。CI やオフライン実行で壊れないこと。 + - 「既存挙動と完全一致」の具体的な意味: JSON/CSV の**キー集合・列集合・列順序**が W&B 導入前と完全に一致すること。 + `02_run_analysis.py` の `result.json` は元々この条件を満たす(`append_to_result_json` は無効時 no-op)。 + `04_multi_video_mae.py` の `results.csv` は Issue #102 で修正: `wandb_run_id` / `exp_key` 列は `USE_WANDB=true` のときのみ追加する。 +8. **ネットワークの無い環境(Raspberry Pi 実機)での計測を第一級ユースケースとする。** + - `WANDB_MODE` 環境変数(`online` / `offline`)を尊重する。offline 時はローカルに記録され、後日 `wandb sync` でアップロードできる。 + - README(または各スクリプトの docstring)に offline 計測 → sync の手順を 3〜4 行で記載すること。 + +--- + +## 2. 記録する情報の定義 + +### 2.1 config(run 開始時に固定) + +| キー | 型 | 説明 | 取得元 | +|---|---|---|---| +| `logic_name` | str | 検出ロジック名。`"line_detection"` または `"roi_counter"` | スクリプト側で固定指定 | +| `dataset` | str | 対象データ名(動画 stem 等) | `VIDEO_SOURCE` から導出 | +| `input_type` | str | `"file"` or `"camera"` | `VIDEO_SOURCE` の型で判定 | +| `device_name` | str | 処理デバイス名(例 `"raspi5"`, `"macbook_m1"`) | 環境変数 `EXP_DEVICE_NAME`(未設定なら `platform.node()` をフォールバック) | +| `device_accelerator` | str | `"cpu"` / `"cuda"` / `"coral"` 等 | 環境変数 or 自動判定 | +| `model_path` | str | 使用した重みファイル名 | 各スクリプトのモデル指定 | +| `frame_width` | int | 入力解像度 幅 | `cv2.CAP_PROP_FRAME_WIDTH` | +| `frame_height` | int | 入力解像度 高さ | `cv2.CAP_PROP_FRAME_HEIGHT` | +| `source_fps` | float | 入力動画の FPS | `cv2.CAP_PROP_FPS` | +| `vehicle_classes` | list | 検出対象クラス | 各スクリプト | +| `tracker_reset` | bool | run 開始時にトラッカー状態をリセットしたか(§4.2 参照) | スクリプト | +| `log_interval_sec` | float | 時系列ログの間引き間隔(秒、相対経過時間ベース)。0 以下・NaN・inf は起動時にエラー(§2.3 参照) | 環境変数 `LOG_INTERVAL_SEC`(既定 5 秒) | +| **YOLO config** | | | | +| `yolo_conf` | float | confidence_threshold | `model.track()` に実際に渡した値と完全一致させる(記録専用の別定数を持たせてはならない) | +| `yolo_iou` | float | iou_threshold | `model.track()` に実際に渡した値と完全一致させる(記録専用の別定数を持たせてはならない) | +| `yolo_device` | str/None | model.track() に渡す device 指定 | 環境変数 `YOLO_DEVICE`(既定 None=Ultralytics 自動選択) | +| **ロジック別パラメータ** | | | | +| `s_low` / `s_high` | float | roi-counter の閾値 | roi-counter のみ | +| `margin` / `max_frame_gap` / `cleanup_threshold` | num | line_detection のパラメータ | line_detection のみ | +| **複合キー(突合用・必須)** | | | | +| `exp_key` | str | §3.1 の `build_exp_key()` で生成 | 共通ユーティリティ | + +### 2.2 summary(run 終了時のスカラ) + +| キー | 型 | 説明 | この実装で値を入れるか | +|---|---|---|---| +| `count_in` | int | 入庫数 | ○ | +| `count_out` | int | 出庫数 | ○ | +| `total_frames` | int | 総フレーム数 | ○ | +| `total_ms` | float | 総処理時間 | ○ | +| `frame_ms_mean` | float | 1 フレーム平均処理時間 | ○ | +| `frame_ms_min` | float | 最速 | ○ | +| `frame_ms_max` | float | 最遅 | ○ | +| `frame_ms_p50` | float | 中央値 | ○ | +| `frame_ms_p95` | float | p95(本番カクつき評価用) | ○ | +| `frame_ms_p99` | float | p99 | ○ | +| `effective_fps` | float | `1000 / frame_ms_mean` | ○ | +| `realtime_ok` | bool | `effective_fps >= source_fps` | ○ | +| `count_error` | int/None | `|in-gt_in| + |out-gt_out|`(GT があれば) | △(GT 次第) | +| `gt_in` / `gt_out` | int/None | 台数の真値 | △ | +| **精度系(後追いで埋める。今は None で確保)** | | | | +| `accuracy` | float/None | | × → `None` | +| `precision` | float/None | | × → `None` | +| `recall` | float/None | | × → `None` | +| `f1` | float/None | | × → `None` | +| `tp` / `fp` / `fn` / `tn` | int/None | 混同行列の素値 | × → `None` | +| `eval_unit` | str/None | `"event"` or `"detection"`(評価単位) | × → `None` | +| `gt_source` | str/None | `"sam3"` / `"manual"` 等 | × → `None` | + +### 2.3 time-series(`wandb.log(step=frame_idx)`) + +| キー | 説明 | +|---|---| +| `frame_ms` | そのフレームの処理時間 | +| `net_flow` | `count_in - count_out` の逐次値(初期駐車台数の絶対値ではなく、あくまで純増減 = net flow) | +| `cumulative_in` | 累積入庫数 | +| `cumulative_out` | 累積出庫数 | +| `num_tracks` | そのフレームで新たにトラッカー ID が割り当てられた検出数(`len(boxes.id)`、無ければ 0)。ROI フィルタ前の値。 | +| `retained_states` | 状態保持ストアのサイズ(roi-counter: `len(counter.tracks)`)。`num_tracks` とは意味が異なる(前者はフレーム単位の新規検出数、後者は累積して保持されている track 状態の総数)ため混同しないこと。 | + +**間引き(必須要件)**: 毎フレーム log すると長時間のカメラ運用で呼び出し回数・帯域が肥大するため、`log_interval_sec`(秒単位、データセット内の相対経過時間 `t_rel_sec` ベース)でサンプリングする。 + +- 既定値: 5 秒(環境変数 `LOG_INTERVAL_SEC` で上書き可)。0 以下・NaN・inf は起動時に検証エラーとし、フェイルファストする(`common.wandb_logger.validate_log_interval_sec` で検証)。 +- 間引き中でも **カウントが変化したフレーム(IN/OUT イベント発生時)は必ず log する**(駐車台数推移の階段が欠けないようにするため)。この強制ログは次回の定期サンプリング境界の計算には影響しない(境界はあくまで `t_rel_sec` のみから算出される)。 +- `step` には常に実フレーム番号 `frame_idx` を使う。x 軸(`step_metric`)は `t_rel_sec`(データセット内の相対経過秒)。 +- 境界計算は while ループではなく除算による O(1) 計算で行う(`common.wandb_logger.next_log_boundary`)。フレーム抜け等で `t_rel_sec` が複数区間分ジャンプしても、1 回の計算で正しい次境界に到達する。 + +--- + +## 3. 実装するモジュール + +### 3.1 共通ユーティリティ `raspi/common/wandb_logger.py`(新規作成) + +両ロジックから共有する薄いラッパを作る。W&B 無効時は no-op になること。 + +要件: + +- `class ExperimentLogger` を定義する。 +- `__init__(self, project: str, config: dict, group: str, job_type: str, tags: list, enabled: bool)` + - `enabled=False` のとき `wandb.init` を呼ばず、以降の全メソッドを no-op にする。 + - `wandb.init(project=..., config=config, group=group, job_type=job_type, tags=tags)` を実行。 + - `WANDB_MODE` は wandb 側の標準挙動に任せる(本ラッパで上書きしない)。 +- `log_frame(self, step: int, metrics: dict)` … `wandb.log(metrics, step=step)`。 +- `set_summary(self, key: str, value)` / `set_summaries(self, d: dict)`。 +- `finish(self, exit_code: int = 0)` … `wandb.finish(exit_code=exit_code)`。 +- **`run_id` プロパティ** … `wandb.run.id`(無効時は `None`)。 +- `save_run_id(self, out_dir: Path)` … `out_dir / "wandb_run_id.txt"` に run.id と exp_key を書き出す。既存の `result.json` がある場合はそこへ `wandb_run_id` / `exp_key` を追記できるヘルパも用意。 +- `init_accuracy_placeholders(self)` … §2.2 の精度系キーを `None` で summary に設定。init 直後に呼ぶ。 +- **`build_exp_key(logic_name: str, dataset: str, device_name: str, params: dict) -> str`(モジュール関数)** + - 形式: `f"{logic_name}__{dataset}__{param_str}__{device_name}"` + - `param_str` は `params` を **キー名の昇順ソート**で `"k1=v1_k2=v2"` に正規化する(float は `f"{v:g}"`)。 + - この関数を両ロジック・後追い評価スクリプトの**全員が共通で使う**こと。各スクリプトで独自フォーマットを組み立ててはならない(突合キーとして機能しなくなるため)。 +- `validate_log_interval_sec(value: float, source: str = "LOG_INTERVAL_SEC") -> float`(モジュール関数)… 0 以下・NaN・inf・不正文字列を拒否し `ValueError` を送出する。起動時に呼ぶこと。 +- `next_log_boundary(next_log_sec, t_rel_sec, log_interval_sec) -> float` / `should_log_frame(t_rel_sec, next_log_sec, count_changed) -> bool`(モジュール関数)… 定期サンプリング境界の判定・進行を while ループなしで行う。 + +実装メモ: +- `wandb` の import は遅延 import(`enabled` のときだけ)にして、未インストール環境で読み込み自体が落ちないようにする。 +- **呼び出し側は `finish()` を必ず `try/finally` で保証する**(§4 各所に明記)。カメラ切断・例外・Ctrl+C でも run が "running" のまま放置されないこと。異常終了時は `exit_code=1` で finish する。 +- 長時間のカメラ運用向けに `update_running_summary(self, d: dict)`(処理途中の count_in/out 等を summary へ随時反映するだけの薄いメソッド)を用意する。クラッシュしても直近の集計が残る。 + +### 3.2 統計ユーティリティ `raspi/common/frame_stats.py`(新規作成) + +- `compute_frame_stats(frame_ms_list: list[float], source_fps: float) -> dict` + - min / max / mean / p50 / p95 / p99 / total_ms / effective_fps / realtime_ok を返す。 + - 空リスト時は全て 0 または `False` を返し例外を出さない。 +- `numpy` を使ってよい(既存依存にあり)。 + +### 3.3 共通モジュールの import 経路(重要・未指定だと迷う) + +両プロジェクトは既に `sys.path.insert(0, <自ルート>)` スタイルなので、これに合わせる。パッケージ化(pip install -e)は今回しない。 + +- `raspi/common/__init__.py` を作成する。 +- 各スクリプトの既存 `sys.path.insert` の直後に、`raspi/` ディレクトリも追加する: + ```python + # 例: raspi/roi-counter/scripts/02_run_analysis.py の場合 + sys.path.insert(0, str(Path(__file__).parents[1])) # 既存: roi-counter/ + sys.path.insert(0, str(Path(__file__).parents[2])) # 追加: raspi/ + from common.wandb_logger import ExperimentLogger, build_exp_key + from common.frame_stats import compute_frame_stats + ``` +- `line_detection/main.py` は `os.path.dirname` スタイルなのでそれに合わせて親ディレクトリを追加する。 + +--- + +## 4. 既存スクリプトへの差し込み + +### 4.1 `roi-counter/scripts/02_run_analysis.py` + +- 冒頭パラメータ群の近くに W&B 設定(`USE_WANDB`, `WANDB_PROJECT`, `EXP_DEVICE_NAME`, `LOG_INTERVAL_SEC` 等)を追加。環境変数で上書き可能にする。 +- `main()` 内: + 1. 既存の `out_dir` 作成後、`config` dict を構築(§2.1 に従う。`logic_name="roi_counter"`, `s_low/s_high` を含める)。 + 2. `build_exp_key()` で `exp_key` を生成し config に入れる。 + 3. `ExperimentLogger` を初期化し、`init_accuracy_placeholders()` を呼ぶ。**以降の処理全体を `try/finally` で包み、finally で `finish()`**(例外時は `exit_code=1`)。 + 4. フレームループ内、既存の `frame_records.append(...)` の直後に、間引き条件(§2.3)を満たすフレームで `logger.log_frame(step=frame_idx, metrics={...})` を追加(`frame_ms`, `net_flow`, `cumulative_in`, `cumulative_out`, `num_tracks`, `retained_states`)。カウント変化時は間引き中でも log。 + 5. ループ後、`compute_frame_stats()` の結果と count 系を `set_summaries`。GT があれば `count_error` も。 + 6. `logger.save_run_id(out_dir)` と `result.json` への `wandb_run_id` / `exp_key` 追記。 +- **既存の CSV / JSON / mp4 出力は一切削らない**。 + +### 4.2 `roi-counter/scripts/04_multi_video_mae.py`(スイープ) + +- **(s_low, s_high, video) の組ごとに 1 run** を作る。時系列 log は行わず、config + summary のみ(run 数 × フレーム数の log は過剰なため)。 +- `run_once()` の戻り値に `frame_times`(生リスト)を含めるよう拡張(p95 等の算出に必要)。 +- **【既存バグ修正・原則 6 の例外】トラッカー状態のリセット**: + - 現状は同一 `YOLO` インスタンスを `persist=True` のまま全組で使い回しており、**前の run のトラック ID 状態が次の run に持ち越される**。これでは run が独立でなく、W&B 上の比較が成立しない。 + - `run_once()` の冒頭でトラッカー状態をリセットすること。実装は次の優先順で試す: + 1. `model.predictor.trackers[0].reset()`(ultralytics のバージョンにより利用可否が変わるため、`hasattr` チェックの上で呼ぶ) + 2. 上記が使えない場合は `YOLO(MODEL_PATH)` を run_once ごとに再生成する(ロード時間は増えるが正しさを優先)。 + - どちらを行ったかに関わらず `config["tracker_reset"] = True` を記録する。リセットできなかった場合は `False` を記録し WARN を出す。 + - 同じ問題は `03_sweep_params.py` にもあるが、本実装のスコープは 04 のみとし、03 は TODO コメントを残す。 +- `main()` のループ内、`run_once` の後で run を 1 つ作り、config・summary(速度統計 + count + count_error + gt)・精度プレースホルダを記録して finish(try/finally)。 +- `detail_rows`(既存 CSV)に `wandb_run_id` / `exp_key` 列を追加して CSV と W&B を相互参照可能にする。 +- 既存の `results.csv` / `mae_summary.csv` 出力は維持する。 + +### 4.3 `line_detection/main.py` + +- `process_video()` に W&B 連携を追加。 +- `config` は `logic_name="line_detection"`, `margin`, `max_frame_gap`, `cleanup_threshold`, `yolo_conf`, `yolo_iou`, `model_path`, 解像度, fps 等。 +- **処理時間の生リストは `process_video()` 内のローカルリストで保持する**(`EventLogger` クラスは変更しない。processing_time_ms を計算している箇所で同じ値を append するだけ)。 +- フレームループで間引き付き `log_frame`。`net_flow` は `tracker.get_net_flow()` 相当の値(`count_in - count_out`)、`num_tracks` はそのフレームで新たにIDが割り当てられた検出数、`retained_states` は `len(tracker.states)`(保持中の状態数、num_tracksとは別概念)。カウント確定時(`mark_as_counted` が呼ばれたフレーム)は間引き中でも log。 +- カメラ入力(`--camera`)は長時間運用になるため、`update_running_summary()` を N フレームごと(例: 300 フレーム)に呼び、クラッシュ時にも直近カウントが summary に残るようにする。 +- ループ後、`tracker.get_summary()` とローカル処理時間リストから summary を構築。全体を try/finally で包み finish を保証(既存の KeyboardInterrupt ハンドリングと整合させる)。 +- 既存の `EventLogger`(JSON/CSV 出力)は維持。`events_*.json` に `wandb_run_id` / `exp_key` を含める(`EventLogger.save_json` の data dict へ引数追加で渡す。デフォルト `None` にして後方互換を保つ)。 +- `argparse` に `--wandb` / `--device-name` を追加してよい。 + +--- + +## 5. 後追い精度評価スクリプト `raspi/eval/update_accuracy_from_sam3.py`(新規作成・雛形のみ) + +SAM3 の GT が揃った後に実行する独立スクリプト。**run の再開ではなく W&B API 経由で既存 run の summary を更新する**方針(実行と評価を分離するため)。 + +要件: + +- 入力: SAM3 由来の評価結果テーブル(per-run の accuracy / precision / recall / f1 / tp/fp/fn/tn と、対応する `exp_key` または `wandb_run_id`。CSV か JSON)。 +- 処理: + 1. `wandb.Api()` を使う。 + 2. 各評価行について、`wandb_run_id` があればそれで run を直接取得。無ければ project 内の run を `config.exp_key` 一致で検索(`api.runs(path, filters={"config.exp_key": key})` を使う。全走査は避ける)。 + 3. `run.summary["accuracy"] = ...` 等を設定し、`run.summary.update()`。 + 4. `eval_unit` / `gt_source="sam3"` も記録。 + 5. 一致する run が 0 件 / 複数件の場合は WARN を出してスキップ(複数件は最新 run を選ぶのではなく人間に判断させる)。 +- **GT の定義を明示するコメントを必ず書く**: + - 台数の最終 GT は人手の真値(既存 `*_gt.json`)を ground truth とする。 + - SAM3 は per-vehicle 軌跡 / イベント列の生成補助に使い、SAM3 出力をそのまま GT としない。 + - 評価単位(`event` / `detection`)はスクリプト冒頭の定数で選択。デフォルトは `event`。 +- マッチングロジック本体は TODO コメントで枠だけ作る(GT データが無いため)。インターフェース(入力フォーマット、出力 summary キー)だけ確定させる。 +- **ダミー入力での動作確認用に、`--dry-run` フラグ**(対象 run と書き込み予定値を表示するだけで書き込まない)を実装する。 + +--- + +## 6. W&B プロジェクト構成の指針(コメントとして残す) + +- `project`: 単一プロジェクト(例 `"tracking-parking"`)。 +- `group`: `logic_name`。 `job_type`: `"speed_eval"` / `"accuracy_eval"`。 `tags`: `device_name`, `input_type`。 +- 1 run = (logic × dataset × params × device) の 1 実行。 +- **オフライン運用**: Raspberry Pi 実機ではネットワークが無い前提で `WANDB_MODE=offline` で実行し、後日開発機で `wandb sync ` する。手順を README に記載。 + +--- + +## 7. 受け入れ条件(Definition of Done) + +1. `USE_WANDB=false` で全スクリプトが従来どおり動作し、出力ファイルが変わらない(`wandb` 未インストールでも動く)。 +2. `USE_WANDB=true` で各 run に config / summary / time-series が記録される。 +3. 全 run の config に `exp_key` が入り、`build_exp_key()` が全スクリプトで共有されている。出力ディレクトリに `wandb_run_id` が保存される。 +4. summary に精度系キーが `None` で存在する。 +5. `update_accuracy_from_sam3.py --dry-run` が、ダミー評価テーブルを入力に対象 run を正しく特定・表示できる。dry-run なしで summary 更新が動作する。 +6. `frame_ms_p95` / `effective_fps` / `realtime_ok` が正しく算出される(`compute_frame_stats` の単体テストで担保)。 +7. 例外・KeyboardInterrupt で中断しても run が finish される(手動確認でよい)。 +8. `04_multi_video_mae.py` で run 間のトラッカー状態リセットが行われ、`tracker_reset` が config に記録される。 +9. `LOG_INTERVAL_SEC` の間引きが機能し、カウント変化フレームは間引き中でも log される。 +10. 既存テスト(`roi-counter/tests/`)が引き続き通る。`raspi/common/tests/` に `frame_stats` と `ExperimentLogger`(enabled=False の no-op、build_exp_key の正規化)の最小テストを追加する。 + +--- + +## 8. 依存関係 + +- `wandb` を `requirements.txt` / `pyproject.toml` に追加(最新安定版を指定)。 +- `wandb` 未インストールでも `USE_WANDB=false` 経路は動くこと(遅延 import)。 + +--- + +## 9. 実装順序の推奨 + +1. `common/frame_stats.py` と `common/wandb_logger.py`(no-op 経路・build_exp_key・テスト含む)。 +2. `roi-counter/scripts/02_run_analysis.py` への差し込み(最小構成の検証)。 +3. `roi-counter/scripts/04_multi_video_mae.py`(スイープ → 複数 run、トラッカーリセット含む)。 +4. `line_detection/main.py`。 +5. `eval/update_accuracy_from_sam3.py`(雛形 + dry-run)。 +6. requirements 更新・README への offline 手順追記。 + +--- + +## 10. オプション(余力があれば。必須ではない) + +- **W&B Artifact**: run 終了時に `vehicles.csv`(roi-counter)/ `events_*.json`(line_detection)を artifact としてアップロードする。per-vehicle の誤検出分析を run 間で見比べられるようになる。offline モードでも artifact はローカル保存され sync 時に上がる。 +- **W&B Table**: `vehicles.csv` の内容を `wandb.Table` として log し、UI 上で track_id ごとの counted_as / state をフィルタ可能にする。 +- どちらも `enabled` フラグとは別のオプトイン設定(`WANDB_UPLOAD_ARTIFACTS=true`)で制御する。 From 65572c4a61095f067fe0fbf7d73b61c278f71531 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:38:37 +0900 Subject: [PATCH 26/57] =?UTF-8?q?feat:=20=E5=87=A6=E7=90=86=E6=99=82?= =?UTF-8?q?=E9=96=93=E3=81=AE=E5=85=B1=E9=80=9A=E8=A8=88=E6=B8=AC=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/common/frame_stats.py | 76 +++++++- raspi/common/frame_timing.py | 182 ++++++++++++++++++ raspi/common/tests/test_frame_stats.py | 18 +- raspi/common/tests/test_frame_timing.py | 91 +++++++++ .../docs/wandb_integration_spec_v2.md | 69 +++++++ 5 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 raspi/common/frame_timing.py create mode 100644 raspi/common/tests/test_frame_timing.py diff --git a/raspi/common/frame_stats.py b/raspi/common/frame_stats.py index 899b1941..7b48e038 100644 --- a/raspi/common/frame_stats.py +++ b/raspi/common/frame_stats.py @@ -5,11 +5,85 @@ 1 フレームごとの処理時間リストから算出する。 """ -from typing import List, Dict +from typing import Any, Dict, List, Sequence import numpy as np +STAT_SUFFIXES = ("min", "max", "mean", "p50", "p95", "p99", "total") + + +def compute_series_stats(values: Sequence[float], prefix: str) -> Dict[str, float]: + """任意のミリ秒系列を、指定した prefix の統計キーへ展開する。""" + + if not values: + return {f"{prefix}_{suffix}": 0.0 for suffix in STAT_SUFFIXES} + + arr = np.asarray(values, dtype=float) + return { + f"{prefix}_min": float(arr.min()), + f"{prefix}_max": float(arr.max()), + f"{prefix}_mean": float(arr.mean()), + f"{prefix}_p50": float(np.percentile(arr, 50)), + f"{prefix}_p95": float(np.percentile(arr, 95)), + f"{prefix}_p99": float(np.percentile(arr, 99)), + f"{prefix}_total": float(arr.sum()), + } + + +def _timing_value(record: Any, key: str) -> float: + if isinstance(record, dict): + return float(record[key]) + return float(getattr(record, key)) + + +def compute_timing_stats(records: Sequence[Any], source_fps: float) -> Dict[str, Any]: + """timing schema v2 のフェーズ別統計とリアルタイム指標を算出する。""" + + phase_names = ( + "read_ms", + "inference_tracking_ms", + "counting_logic_ms", + "core_ms", + "output_ms", + "end_to_end_ms", + ) + stats: Dict[str, Any] = {} + for phase in phase_names: + stats.update(compute_series_stats([_timing_value(r, phase) for r in records], phase)) + + core_mean = stats["core_ms_mean"] + end_to_end_mean = stats["end_to_end_ms_mean"] + stats["core_effective_fps"] = 1000.0 / core_mean if core_mean > 0 else 0.0 + stats["end_to_end_effective_fps"] = ( + 1000.0 / end_to_end_mean if end_to_end_mean > 0 else 0.0 + ) + + budget_ms = 1000.0 / source_fps if source_fps > 0 else 0.0 + miss_count = ( + sum(_timing_value(r, "end_to_end_ms") > budget_ms for r in records) + if budget_ms > 0 + else 0 + ) + stats["frame_budget_ms"] = budget_ms + stats["deadline_miss_count"] = int(miss_count) + stats["deadline_miss_rate"] = float(miss_count / len(records)) if records else 0.0 + stats["core_realtime_ok"] = ( + bool(stats["core_effective_fps"] >= source_fps) if source_fps > 0 else False + ) + stats["end_to_end_realtime_ok"] = ( + bool(records and source_fps > 0 and miss_count == 0) + ) + + # timing schema v1 互換。新しい比較・リアルタイム判定には上記の明示名を使う。 + for suffix in STAT_SUFFIXES: + stats[f"frame_ms_{suffix}"] = stats[f"core_ms_{suffix}"] + stats["total_ms"] = stats["core_ms_total"] + stats["effective_fps"] = stats["core_effective_fps"] + stats["realtime_ok"] = stats["core_realtime_ok"] + return stats + + def compute_frame_stats(frame_ms_list: List[float], source_fps: float) -> Dict: """ フレーム処理時間リストから速度統計を算出する。 diff --git a/raspi/common/frame_timing.py b/raspi/common/frame_timing.py new file mode 100644 index 00000000..94ce08d5 --- /dev/null +++ b/raspi/common/frame_timing.py @@ -0,0 +1,182 @@ +"""両検出方式で共有するフレーム速度計測契約。""" + +from __future__ import annotations + +import hashlib +import json +import time +from contextlib import contextmanager +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Callable, Iterator, Mapping, Any + + +TIMING_SCHEMA_VERSION = 2 +DEFAULT_WARMUP_FRAMES = 30 + +COMPARISON_CONFIG_KEYS = ( + "input_sha256", + "model_sha256", + "frame_width", + "frame_height", + "source_fps", + "vehicle_classes", + "yolo_conf", + "yolo_iou", + "yolo_imgsz", + "tracker_config", + "yolo_device", + "device_name", + "device_accelerator", + "warmup_frames", + "save_video", + "show_display", + "timing_schema_version", +) + + +@dataclass +class Elapsed: + """``elapsed_ms`` をコンテキスト終了後に参照するための入れ物。""" + + elapsed_ms: float = 0.0 + + +@contextmanager +def elapsed_timer( + synchronize: Callable[[], None] | None = None, +) -> Iterator[Elapsed]: + """単調時計で処理時間を測る。非同期デバイスは前後で同期する。""" + + if synchronize is not None: + synchronize() + started = time.perf_counter() + elapsed = Elapsed() + try: + yield elapsed + finally: + if synchronize is not None: + synchronize() + elapsed.elapsed_ms = (time.perf_counter() - started) * 1000.0 + + +def validate_warmup_frames(value: int | str) -> int: + """warm-up フレーム数を検証する。""" + + try: + frames = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"WARMUP_FRAMES must be a non-negative integer: {value!r}") from exc + if frames < 0: + raise ValueError(f"WARMUP_FRAMES must be >= 0: {frames}") + return frames + + +def resolve_model_device(model: Any, configured_device: Any = None) -> str: + """明示設定を優先し、Ultralytics モデルの実デバイスを推定する。""" + + if configured_device is not None: + return str(configured_device) + + for candidate in (getattr(model, "device", None), getattr(getattr(model, "model", None), "device", None)): + if candidate is not None: + return str(candidate) + + inner = getattr(model, "model", None) + if inner is not None: + try: + return str(next(inner.parameters()).device) + except (AttributeError, StopIteration, TypeError): + pass + return "cpu" + + +def synchronize_device(device: Any) -> None: + """CUDA/MPS のキュー完了を待つ。CPU は no-op。""" + + normalized = str(device).lower().strip() + if normalized.isdigit() or normalized.startswith("cuda"): + import torch + + if torch.cuda.is_available(): + torch.cuda.synchronize() + elif normalized.startswith("mps"): + import torch + + if hasattr(torch, "mps") and torch.backends.mps.is_available(): + torch.mps.synchronize() + + +def model_synchronizer(model: Any, configured_device: Any = None) -> Callable[[], None]: + """モデルの現在デバイスを毎回確認する同期関数を返す。""" + + return lambda: synchronize_device(resolve_model_device(model, configured_device)) + + +@lru_cache(maxsize=32) +def sha256_file(path: str) -> str | None: + """比較対象ファイルの SHA-256。カメラ等の非ファイル入力は ``None``。""" + + target = Path(path) + if not target.is_file(): + return None + digest = hashlib.sha256() + with target.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_comparison_key(config: Mapping[str, Any]) -> str: + """速度比較条件を正規化し、同一条件を表すキーを生成する。""" + + normalized = {key: config.get(key) for key in COMPARISON_CONFIG_KEYS} + payload = json.dumps(normalized, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class FrameTiming: + """成功した1フレームのフェーズ別処理時間。""" + + frame_index: int + read_ms: float + inference_tracking_ms: float + counting_logic_ms: float + output_ms: float + end_to_end_ms: float + is_warmup: bool + + @property + def core_ms(self) -> float: + return self.inference_tracking_ms + self.counting_logic_ms + + def to_dict(self) -> dict[str, Any]: + return { + "frame_index": self.frame_index, + "is_warmup": self.is_warmup, + "read_ms": self.read_ms, + "inference_tracking_ms": self.inference_tracking_ms, + "counting_logic_ms": self.counting_logic_ms, + "core_ms": self.core_ms, + "output_ms": self.output_ms, + "end_to_end_ms": self.end_to_end_ms, + # timing schema v1 との互換 alias。新規比較では core_ms を使う。 + "frame_ms": self.core_ms, + } + + +def measured_timings(records: list[FrameTiming]) -> list[FrameTiming]: + """warm-up ではない計測レコードだけを返す。""" + + return [record for record in records if not record.is_warmup] + + +def require_measured_timings(records: list[FrameTiming]) -> list[FrameTiming]: + """比較可能なフレームが無い run を成功値として扱わない。""" + + measured = measured_timings(records) + if not measured: + raise ValueError("No measured frames remain after warm-up") + return measured diff --git a/raspi/common/tests/test_frame_stats.py b/raspi/common/tests/test_frame_stats.py index e59746bc..9a07fceb 100644 --- a/raspi/common/tests/test_frame_stats.py +++ b/raspi/common/tests/test_frame_stats.py @@ -1,6 +1,7 @@ import numpy as np -from common.frame_stats import compute_frame_stats +from common.frame_stats import compute_frame_stats, compute_timing_stats +from common.frame_timing import FrameTiming def test_basic_stats(): @@ -39,3 +40,18 @@ def test_zero_source_fps(): # source_fps=0 でも例外を出さず realtime_ok=False stats = compute_frame_stats([10.0, 20.0], source_fps=0.0) assert stats["realtime_ok"] is False + + +def test_timing_stats_use_core_for_comparison_and_end_to_end_for_deadline(): + records = [ + FrameTiming(0, 1.0, 20.0, 5.0, 2.0, 30.0, False), + FrameTiming(1, 1.0, 25.0, 5.0, 2.0, 40.0, False), + ] + stats = compute_timing_stats(records, source_fps=30.0) + + assert stats["core_ms_mean"] == 27.5 + assert stats["frame_ms_mean"] == stats["core_ms_mean"] + assert stats["frame_budget_ms"] == 1000.0 / 30.0 + assert stats["deadline_miss_count"] == 1 + assert stats["deadline_miss_rate"] == 0.5 + assert stats["end_to_end_realtime_ok"] is False diff --git a/raspi/common/tests/test_frame_timing.py b/raspi/common/tests/test_frame_timing.py new file mode 100644 index 00000000..6b2b6f9d --- /dev/null +++ b/raspi/common/tests/test_frame_timing.py @@ -0,0 +1,91 @@ +import hashlib + +import pytest + +from common.frame_timing import ( + FrameTiming, + build_comparison_key, + elapsed_timer, + measured_timings, + require_measured_timings, + validate_warmup_frames, +) + + +def make_timing(index: int, warmup: bool) -> FrameTiming: + return FrameTiming( + frame_index=index, + read_ms=1.0, + inference_tracking_ms=10.0, + counting_logic_ms=2.0, + output_ms=3.0, + end_to_end_ms=17.0, + is_warmup=warmup, + ) + + +def test_frame_timing_core_and_legacy_alias(): + row = make_timing(0, False).to_dict() + assert row["core_ms"] == 12.0 + assert row["frame_ms"] == row["core_ms"] + + +def test_measured_timings_excludes_warmup(): + records = [make_timing(0, True), make_timing(1, False)] + assert [record.frame_index for record in measured_timings(records)] == [1] + + +def test_require_measured_timings_rejects_all_warmup(): + with pytest.raises(ValueError, match="No measured frames"): + require_measured_timings([make_timing(0, True)]) + + +@pytest.mark.parametrize("value, expected", [(0, 0), ("30", 30)]) +def test_validate_warmup_frames(value, expected): + assert validate_warmup_frames(value) == expected + + +@pytest.mark.parametrize("value", [-1, "invalid"]) +def test_validate_warmup_frames_rejects_invalid(value): + with pytest.raises(ValueError): + validate_warmup_frames(value) + + +def test_comparison_key_is_stable_and_sensitive_to_conditions(): + base = { + "input_sha256": "input", + "model_sha256": "model", + "frame_width": 1920, + "frame_height": 1080, + "source_fps": 30.0, + "vehicle_classes": [2, 7], + "yolo_conf": 0.25, + "yolo_iou": 0.7, + "yolo_imgsz": 640, + "tracker_config": "botsort.yaml", + "yolo_device": "cpu", + "device_name": "raspi5", + "device_accelerator": "cpu", + "warmup_frames": 30, + "save_video": False, + "show_display": False, + "timing_schema_version": 2, + } + first = build_comparison_key(base) + assert first == build_comparison_key(dict(reversed(list(base.items())))) + assert len(first) == hashlib.sha256().digest_size * 2 + + changed = dict(base, yolo_imgsz=1280) + assert build_comparison_key(changed) != first + + +def test_elapsed_timer_synchronizes_before_and_after(monkeypatch): + clock = iter([1.0, 1.025]) + monkeypatch.setattr("common.frame_timing.time.perf_counter", lambda: next(clock)) + sync_calls = [] + + with elapsed_timer(lambda: sync_calls.append("sync")) as elapsed: + pass + + assert sync_calls == ["sync", "sync"] + assert elapsed.elapsed_ms == pytest.approx(25.0) diff --git a/raspi/line_detection/docs/wandb_integration_spec_v2.md b/raspi/line_detection/docs/wandb_integration_spec_v2.md index eda6fef2..11a2d6d1 100644 --- a/raspi/line_detection/docs/wandb_integration_spec_v2.md +++ b/raspi/line_detection/docs/wandb_integration_spec_v2.md @@ -117,6 +117,75 @@ - `step` には常に実フレーム番号 `frame_idx` を使う。x 軸(`step_metric`)は `t_rel_sec`(データセット内の相対経過秒)。 - 境界計算は while ループではなく除算による O(1) 計算で行う(`common.wandb_logger.next_log_boundary`)。フレーム抜け等で `t_rel_sec` が複数区間分ジャンプしても、1 回の計算で正しい次境界に到達する。 +### 2.4 速度計測契約(Issue #92、timing schema v2) + +ROI 方式と 2 ライン方式の速度を直接比較できるよう、両方式は +`common/frame_timing.py` の同一フェーズ定義を使う。計測時計は単調増加する +`time.perf_counter()` とし、単位はすべてミリ秒とする。 + +| キー | 開始 | 終了 | 含める処理 | +|---|---|---|---| +| `read_ms` | `cap.read()` の直前 | 成功したフレームを受け取った直後 | 動画デコード、カメラのフレーム待ち | +| `inference_tracking_ms` | `model.track()` の直前 | bbox と track ID を CPU 上で利用可能な値に変換した後 | YOLO 前処理・推論・後処理・追跡・デバイスから CPU への転送 | +| `counting_logic_ms` | CPU 化済みの bbox/ID を受け取った直後 | 方式固有の状態更新と stale track cleanup の終了後 | ROI/進行度判定、またはライン交差判定と IN/OUT 確定 | +| `core_ms` | 算出値 | 算出値 | `inference_tracking_ms + counting_logic_ms`。方式比較の主対象 | +| `output_ms` | イベント・描画出力の直前 | 動画書き込み・画面表示の終了後 | イベント記録、アノテーション、`VideoWriter.write()`、`imshow()`、`waitKey()` | +| `end_to_end_ms` | `cap.read()` の直前 | `output_ms` の終了後 | 1 フレームの本番処理全体。ただし後述の観測処理は除外 | + +`wandb.log()`、W&B summary 更新、進捗表示は観測処理であり、ネットワークや +ローカルバッファの状態を検出方式の速度へ混入させないため、すべての計測区間から +除外する。計測済みの `end_to_end_ms` を確定してからログを送る。 + +方式選定の主指標は `core_ms_p95` とする。p95 は warm-up 除外後の 95% の +フレームがその時間以内に完了したことを表し、平均値では見えにくい実運用上の +カクつきを評価できる。`p50`、`p99`、`mean`、`min`、`max` も補助指標として保存する。 + +Raspberry Pi 上のリアルタイム性は `end_to_end_ms` で評価する。フレーム予算と +締切超過率は次式で定義する。 + +```text +frame_budget_ms = 1000 / source_fps +deadline_miss_rate = count(end_to_end_ms > frame_budget_ms) / measured_frames +``` + +summary には `end_to_end_effective_fps`、`end_to_end_realtime_ok`、 +`deadline_miss_count`、`deadline_miss_rate` を保存する。既存の `frame_ms` 系キーと +`effective_fps` / `realtime_ok` は互換性のため `core_ms` 系の deprecated alias として +残し、新規のリアルタイム判定には使用しない。 + +#### warm-up + +- 両方式とも `WARMUP_FRAMES`(既定 30)を使用し、config に `warmup_frames` を保存する。 +- warm-up 中も検出、追跡、カウント、出力は通常どおり実行し、速度 summary からだけ除外する。 +- summary に `measured_frames` を保存する。 +- `total_frames <= warmup_frames` の run は比較可能な統計を作れないためエラーとし、0 ms の有効値として保存しない。 + +#### 非同期デバイスの同期 + +CUDA/MPS などでは処理投入と完了が非同期になり得るため、 +`inference_tracking_ms` の開始直前と終了時刻取得直前にデバイス同期を行う。 +CPU は no-op とする。これにより GPU 処理が完了する前にタイマーだけ止まることを防ぐ。 + +#### 同一条件 run の判定 + +速度比較に使う run は、次の共通条件が一致しなければならない。 + +| config | 意味と必要性 | +|---|---| +| `input_sha256` | 同名でも内容が異なる動画を区別する | +| `model_sha256` | 同名でも重みが異なるモデルを区別する | +| `frame_width` / `frame_height` / `source_fps` | 入力画素数とフレーム予算を固定する | +| `vehicle_classes` | 検出・NMS・追跡対象を固定する | +| `yolo_conf` / `yolo_iou` / `yolo_imgsz` | 候補数、後処理量、推論入力サイズを固定する | +| `tracker_config` | ByteTrack/BoT-SORT とその閾値を固定する | +| `device_name` / `device_accelerator` / `yolo_device` | 実機名と CPU/CUDA/MPS 等の実行先を固定する | +| `warmup_frames` | 統計から除外する範囲を固定する | +| `save_video` / `show_display` | `output_ms` と `end_to_end_ms` の出力条件を固定する | +| `timing_schema_version` | 計測区間の定義が同じ run だけを比較する | + +上記を正規化して SHA-256 化した `comparison_key` を config に保存する。 +W&B では `comparison_key` が一致する ROI / 2 ライン run のみを直接比較する。 + --- ## 3. 実装するモジュール From a0e6d82998e100759bd3c7f8397174397407efb7 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:01:51 +0900 Subject: [PATCH 27/57] =?UTF-8?q?feat:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E3=81=AE=E5=87=A6=E7=90=86=E6=99=82=E9=96=93?= =?UTF-8?q?=E8=A8=88=E6=B8=AC=E3=82=92=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + raspi/line_detection/.env.template | 18 +- raspi/line_detection/README.md | 28 ++ raspi/line_detection/main.py | 433 +++++++++++++----- raspi/line_detection/requirements.txt | 3 + .../result_output/event_logger.py | 48 +- raspi/line_detection/tests/__init__.py | 0 .../line_detection/tests/test_event_logger.py | 53 +++ uv.lock | 233 ++++++++++ 9 files changed, 686 insertions(+), 131 deletions(-) create mode 100644 raspi/line_detection/tests/__init__.py create mode 100644 raspi/line_detection/tests/test_event_logger.py diff --git a/pyproject.toml b/pyproject.toml index 69251a1d..1756d41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,4 +19,5 @@ dependencies = [ # データ処理 "pandas>=2.2.1", "lap>=0.5.13", + "wandb>=0.28.0", ] diff --git a/raspi/line_detection/.env.template b/raspi/line_detection/.env.template index e77a2ad5..e6ee1bcc 100644 --- a/raspi/line_detection/.env.template +++ b/raspi/line_detection/.env.template @@ -3,7 +3,17 @@ HOME_DIR=/Users/ycn/Workspace/NUTMEG/tracking-parking # YOLOモデル MODEL_PATH=${HOME_DIR}/yolo_fine_tuning/runs/detect/train14/weights/best.pt -CONFIDENCE_THRESHOLD=0.3 +CONFIDENCE_THRESHOLD=0.25 +IOU_THRESHOLD=0.7 +VEHICLE_CLASSES=2,7 + +# 速度比較条件(ROI方式と同じ値に揃える) +YOLO_DEVICE=cpu +YOLO_IMGSZ=640 +YOLO_TRACKER=botsort.yaml +WARMUP_FRAMES=30 +EXP_DEVICE_NAME=raspi5 +EXP_DEVICE_ACCELERATOR=cpu # Line1設定(入口側ライン) - setup/setup_lines.pyで自動設定 LINE1_START_X=300 @@ -33,3 +43,9 @@ METHOD=hybrid SAVE_VIDEO=true SAVE_LOGS=true SHOW_DISPLAY=false + +# W&B(実機では offline を推奨) +USE_WANDB=false +WANDB_PROJECT=tracking-parking +WANDB_MODE=offline +LOG_INTERVAL_SEC=5 diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md index ed69643d..c6b3f30a 100644 --- a/raspi/line_detection/README.md +++ b/raspi/line_detection/README.md @@ -60,6 +60,7 @@ uv sync - numpy (数値演算) - torch, torchvision (深層学習) - pandas (データ処理) +- wandb (実験管理・offline記録) ### 2. ライン座標の設定 @@ -119,6 +120,33 @@ python main.py --camera 0 --display python main.py --input data/inputs/test.mp4 --output /path/to/output ``` +### 方式間の速度比較 + +速度計測は timing schema v2 に従い、`read_ms`、`inference_tracking_ms`、 +`counting_logic_ms`、`core_ms`、`output_ms`、`end_to_end_ms` に分割する。 +方式比較では warm-up 除外後の `core_ms_p95`、実機のリアルタイム判定では +`end_to_end_ms` と `deadline_miss_rate` を使用する。 + +ROI方式と同じ動画・モデル・classes・confidence・IoU・image size・tracker・device・ +warm-up・動画保存/表示設定を指定して実行する。 +ROI方式の現在の既定値は`VEHICLE_CLASSES=2,7`、`CONFIDENCE_THRESHOLD=0.25`、 +`IOU_THRESHOLD=0.7`であり、`.env.template`もこの比較条件に揃えている。 + +```bash +WARMUP_FRAMES=30 YOLO_DEVICE=cpu YOLO_IMGSZ=640 \ +YOLO_TRACKER=botsort.yaml SAVE_VIDEO=false SHOW_DISPLAY=false \ +WANDB_MODE=offline python main.py --input data/inputs/test.mp4 \ + --wandb --device-name raspi5 +``` + +入力・モデルのSHA-256と上記条件から生成した`comparison_key`が同じrunだけを +直接比較する。W&B送信は計測区間外で行うため、online/offlineの通信状態は +速度値に含まれない。offline runは後日次のように同期する。 + +```bash +wandb sync +``` + ## 出力ファイル ### アノテーション動画 (`data/outputs/videos/`) diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index da5cf1a5..0373126b 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -8,21 +8,83 @@ import cv2 import argparse import os +import platform import sys -import time +from dataclasses import dataclass +from pathlib import Path from ultralytics import YOLO -# 親ディレクトリをパスに追加 +# 自ディレクトリと raspi/ をパスに追加 sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from detection.config import Config from detection.line_crossing import LineCrossingDetector, get_vehicle_point from detection.tracker import VehicleTracker from result_output.video_writer import VideoAnnotator from result_output.event_logger import EventLogger +from common.frame_stats import compute_timing_stats +from common.frame_timing import ( + DEFAULT_WARMUP_FRAMES, + TIMING_SCHEMA_VERSION, + FrameTiming, + build_comparison_key, + elapsed_timer, + model_synchronizer, + require_measured_timings, + sha256_file, + validate_warmup_frames, +) +from common.wandb_logger import ( + ExperimentLogger, + build_exp_key, + next_log_boundary, + should_log_frame, + validate_log_interval_sec, +) + + +WEBCAM_FPS = 30.0 + + +@dataclass(frozen=True) +class RuntimeSettings: + use_wandb: bool + wandb_project: str + device_accelerator: str + log_interval_sec: float + yolo_device: str | None + yolo_imgsz: int + yolo_tracker: str + warmup_frames: int + + @classmethod + def from_env(cls) -> "RuntimeSettings": + """Config.from_env() が .env を読み込んだ後に呼ぶ。""" + + return cls( + use_wandb=os.getenv("USE_WANDB", "false").lower() == "true", + wandb_project=os.getenv("WANDB_PROJECT", "tracking-parking"), + device_accelerator=os.getenv("EXP_DEVICE_ACCELERATOR", "cpu"), + log_interval_sec=validate_log_interval_sec(os.getenv("LOG_INTERVAL_SEC", "5")), + yolo_device=os.getenv("YOLO_DEVICE") or None, + yolo_imgsz=int(os.getenv("YOLO_IMGSZ", "640")), + yolo_tracker=os.getenv("YOLO_TRACKER", "botsort.yaml"), + warmup_frames=validate_warmup_frames( + os.getenv("WARMUP_FRAMES", DEFAULT_WARMUP_FRAMES) + ), + ) -def process_video(video_path: str, config: Config, output_dir: str): +def process_video( + video_path: str | int, + config: Config, + output_dir: str, + *, + use_wandb: bool = False, + device_name: str = platform.node(), + runtime: RuntimeSettings | None = None, +): """ 動画を処理 @@ -31,6 +93,8 @@ def process_video(video_path: str, config: Config, output_dir: str): config: 設定 output_dir: 出力ディレクトリ """ + runtime = runtime or RuntimeSettings.from_env() + print("\n" + "=" * 60) print("2ライン検知システム") print("=" * 60) @@ -46,14 +110,14 @@ def process_video(video_path: str, config: Config, output_dir: str): print(f"✓ YOLOモデル読み込み: {config.model_path}") # 動画キャプチャを開く - cap = cv2.VideoCapture(video_path if video_path else 0) + cap = cv2.VideoCapture(video_path) if not cap.isOpened(): print(f"エラー: 動画を開けません: {video_path}") return # 動画情報を取得 - fps = cap.get(cv2.CAP_PROP_FPS) + fps = cap.get(cv2.CAP_PROP_FPS) or WEBCAM_FPS width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) @@ -85,7 +149,7 @@ def process_video(video_path: str, config: Config, output_dir: str): print("✓ アノテーター初期化") # イベントロガーを初期化 - logger = EventLogger(video_path=video_path) + event_logger = EventLogger(video_path=video_path) print("✓ イベントロガー初期化") # 出力動画ライターを初期化 @@ -95,7 +159,7 @@ def process_video(video_path: str, config: Config, output_dir: str): output_video_path = os.path.join( output_dir, "videos", - f"annotated_{os.path.basename(video_path)}" + f"annotated_{Path(str(video_path)).name if isinstance(video_path, str) else 'camera.mp4'}" ) fourcc = cv2.VideoWriter_fourcc(*'mp4v') out = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height)) @@ -103,126 +167,237 @@ def process_video(video_path: str, config: Config, output_dir: str): print("\n処理開始...\n") + input_type = "file" if isinstance(video_path, str) else "camera" + dataset = Path(str(video_path)).stem if isinstance(video_path, str) else f"camera_{video_path}" + exp_params = { + "cleanup_threshold": config.cleanup_threshold, + "margin": config.margin, + "max_frame_gap": config.max_frame_gap, + } + run_config = { + "logic_name": "line_detection", + "dataset": dataset, + "input_type": input_type, + "device_name": device_name, + "device_accelerator": runtime.device_accelerator, + "model_path": config.model_path, + "input_sha256": sha256_file(str(video_path)) if isinstance(video_path, str) else None, + "model_sha256": sha256_file(config.model_path), + "frame_width": width, + "frame_height": height, + "source_fps": float(fps), + "vehicle_classes": config.vehicle_classes, + "tracker_reset": True, + "log_interval_sec": runtime.log_interval_sec, + "yolo_conf": config.confidence_threshold, + "yolo_iou": config.iou_threshold, + "yolo_device": runtime.yolo_device, + "yolo_imgsz": runtime.yolo_imgsz, + "tracker_config": runtime.yolo_tracker, + "warmup_frames": runtime.warmup_frames, + "save_video": config.save_video, + "show_display": config.show_display, + "timing_schema_version": TIMING_SCHEMA_VERSION, + **exp_params, + } + run_config["comparison_key"] = build_comparison_key(run_config) + run_config["exp_key"] = build_exp_key( + "line_detection", dataset, device_name, exp_params + ) + wandb_logger = ExperimentLogger( + project=runtime.wandb_project, + config=run_config, + group="line_detection", + job_type="speed_eval", + tags=[device_name, input_type], + enabled=use_wandb, + ) + wandb_logger.init_accuracy_placeholders() + wandb_logger.define_metric("net_flow", step_metric="t_rel_sec") + # 2. フレーム毎処理 frame_id = 0 + timing_records: list[FrameTiming] = [] + next_log_sec = 0.0 + prev_count_in = 0 + prev_count_out = 0 + synchronize_model = model_synchronizer(model, runtime.yolo_device) + exit_code = 0 - while cap.isOpened(): - start_time = time.perf_counter() - - ret, frame = cap.read() - if not ret: - break - - # 2.1 YOLO検知+トラッキング(車両クラスのみ) - results = model.track( - frame, - persist=True, - conf=config.confidence_threshold, - iou=config.iou_threshold, - classes=config.vehicle_classes, - verbose=False - ) - - # 2.2 各車両の処理 - if results[0].boxes.id is not None: - track_ids = results[0].boxes.id.int().tolist() - bboxes = results[0].boxes.xyxy.tolist() - - for track_id, bbox in zip(track_ids, bboxes): - # 車両代表点取得 - vehicle_point = get_vehicle_point(bbox) - - # 状態更新 - state = tracker.update(track_id, vehicle_point, frame_id) - - # ライン交差検知 - if state.prev_point is not None: - # Line1交差検知 - line1_dir = detector.detect_line1_crossing( - state.prev_point, - state.curr_point - ) - - # Line2交差検知 - line2_dir = detector.detect_line2_crossing( - state.prev_point, - state.curr_point + try: + while cap.isOpened(): + with elapsed_timer() as end_to_end_timer: + with elapsed_timer() as read_timer: + ret, frame = cap.read() + if not ret: + break + + # 2.1 YOLO検知+トラッキング。CPU化までを共通推論区間に含める。 + with elapsed_timer(synchronize_model) as inference_timer: + results = model.track( + frame, + persist=True, + conf=config.confidence_threshold, + iou=config.iou_threshold, + classes=config.vehicle_classes, + verbose=False, + device=runtime.yolo_device, + imgsz=runtime.yolo_imgsz, + tracker=runtime.yolo_tracker, ) - - # Line1交差イベントを記録 - if line1_dir and not state.counted: - state.record_line1_crossing(line1_dir, frame_id) - - # Line2交差を記録 - if line2_dir: - state.record_line2_crossing(line2_dir, frame_id) - - # ハイブリッド方式でイベント判定 - if tracker.should_count_event(state): - # カウント済みとしてマーク - event_type = tracker.mark_as_counted(track_id) - - # イベントをログに記録 - logger.record_event( - track_id=track_id, - event_type=event_type, - frame_id=frame_id, - fps=fps, - confidence=state.confidence, - line2_crossed=(state.line2_direction is not None) + boxes = results[0].boxes + if boxes.id is None: + detections = [] + else: + detections = list(zip( + boxes.id.int().cpu().tolist(), + boxes.xyxy.cpu().tolist(), + )) + + pending_events = [] + with elapsed_timer() as counting_timer: + for track_id, bbox in detections: + vehicle_point = get_vehicle_point(bbox) + state = tracker.update(track_id, vehicle_point, frame_id) + if state.prev_point is None: + continue + + line1_dir = detector.detect_line1_crossing( + state.prev_point, state.curr_point ) - - print(f"[Frame {frame_id}] ID:{track_id} {event_type} (信頼度: {state.confidence})") - - # 2.3 古い追跡をクリーンアップ - tracker.cleanup_stale_tracks(frame_id) - - # 2.4 処理時間を記録 - processing_time_ms = (time.perf_counter() - start_time) * 1000 - logger.record_frame_time(processing_time_ms) - - # 2.5 アノテーション - annotated_frame = annotator.annotate_frame( - frame, - tracker, - frame_id, - processing_time_ms - ) - - # 2.6 出力 - if config.save_video and out: - out.write(annotated_frame) - - if config.show_display: - cv2.imshow("2ライン検知", annotated_frame) - if cv2.waitKey(1) & 0xFF == ord('q'): + line2_dir = detector.detect_line2_crossing( + state.prev_point, state.curr_point + ) + if line1_dir and not state.counted: + state.record_line1_crossing(line1_dir, frame_id) + if line2_dir: + state.record_line2_crossing(line2_dir, frame_id) + if tracker.should_count_event(state): + event_type = tracker.mark_as_counted(track_id) + pending_events.append({ + "track_id": track_id, + "event_type": event_type, + "frame_id": frame_id, + "fps": fps, + "confidence": state.confidence, + "line2_crossed": state.line2_direction is not None, + }) + tracker.cleanup_stale_tracks(frame_id) + + core_ms = inference_timer.elapsed_ms + counting_timer.elapsed_ms + quit_requested = False + with elapsed_timer() as output_timer: + for event in pending_events: + event_logger.record_event(**event) + if config.save_video or config.show_display: + annotated_frame = annotator.annotate_frame( + frame, tracker, frame_id, core_ms + ) + else: + annotated_frame = frame + if config.save_video and out: + out.write(annotated_frame) + if config.show_display: + cv2.imshow("2ライン検知", annotated_frame) + quit_requested = cv2.waitKey(1) & 0xFF == ord("q") + + timing = FrameTiming( + frame_index=frame_id, + read_ms=read_timer.elapsed_ms, + inference_tracking_ms=inference_timer.elapsed_ms, + counting_logic_ms=counting_timer.elapsed_ms, + output_ms=output_timer.elapsed_ms, + end_to_end_ms=end_to_end_timer.elapsed_ms, + is_warmup=frame_id < runtime.warmup_frames, + ) + timing_records.append(timing) + event_logger.record_frame_time(timing.core_ms) + + # 観測処理は end_to_end 計測後に行う。 + summary = tracker.get_summary() + count_in = summary["total_in"] + count_out = summary["total_out"] + count_changed = count_in != prev_count_in or count_out != prev_count_out + t_rel_sec = frame_id / fps if fps > 0 else 0.0 + if should_log_frame(t_rel_sec, next_log_sec, count_changed): + wandb_logger.log_frame( + step=frame_id, + metrics={ + "t_rel_sec": t_rel_sec, + "net_flow": count_in - count_out, + "cumulative_in": count_in, + "cumulative_out": count_out, + **timing.to_dict(), + "num_tracks": len(detections), + "retained_states": len(tracker.states), + }, + ) + next_log_sec = next_log_boundary( + next_log_sec, t_rel_sec, runtime.log_interval_sec + ) + prev_count_in = count_in + prev_count_out = count_out + + if frame_id % 300 == 0: + wandb_logger.update_running_summary({ + "count_in": count_in, + "count_out": count_out, + "total_frames": frame_id + 1, + }) + if frame_id % 30 == 0: + progress = (frame_id / total_frames * 100) if total_frames > 0 else 0 + print(f"処理中... {frame_id}/{total_frames}フレーム ({progress:.1f}%)") + for event in pending_events: + print( + f"[Frame {frame_id}] ID:{event['track_id']} " + f"{event['event_type']} (信頼度: {event['confidence']})" + ) + + frame_id += 1 + if quit_requested: print("\nユーザーによる中断") break - # 進捗表示 - if frame_id % 30 == 0: - progress = (frame_id / total_frames * 100) if total_frames > 0 else 0 - print(f"処理中... {frame_id}/{total_frames}フレーム ({progress:.1f}%)") - - frame_id += 1 - - # 3. クリーンアップ - cap.release() - if out: - out.release() - if config.show_display: - cv2.destroyAllWindows() - - print("\n処理完了!") - - # 4. ログを保存 - if config.save_logs: - log_dir = os.path.join(output_dir, "logs") - logger.save_json(log_dir, tracker.get_summary()) - logger.save_csv(log_dir) - - # 5. サマリーを表示 - logger.print_summary(tracker.get_summary()) + measured = require_measured_timings(timing_records) + timing_stats = compute_timing_stats(measured, float(fps)) + tracker_summary = tracker.get_summary() + wandb_logger.set_summaries({ + "count_in": tracker_summary["total_in"], + "count_out": tracker_summary["total_out"], + "total_frames": frame_id, + "measured_frames": len(measured), + **timing_stats, + }) + + print("\n処理完了!") + log_dir = Path(output_dir) / "logs" + if config.save_logs: + event_logger.save_json( + str(log_dir), + tracker_summary, + wandb_run_id=wandb_logger.run_id, + exp_key=run_config["exp_key"] if use_wandb else None, + timing_summary={ + "timing_schema_version": TIMING_SCHEMA_VERSION, + "warmup_frames": runtime.warmup_frames, + "measured_frames": len(measured), + **timing_stats, + }, + ) + event_logger.save_csv(str(log_dir)) + wandb_logger.save_run_id(log_dir) + event_logger.print_summary(tracker_summary, timing_summary=timing_stats) + return timing_stats + except BaseException: + exit_code = 1 + raise + finally: + cap.release() + if out: + out.release() + if config.show_display: + cv2.destroyAllWindows() + wandb_logger.finish(exit_code=exit_code) def main(): @@ -254,6 +429,16 @@ def main(): action="store_true", help="処理中の動画を表示" ) + parser.add_argument( + "--wandb", + action="store_true", + help="W&Bへ速度・台数メトリクスを記録" + ) + parser.add_argument( + "--device-name", + default=None, + help="比較対象デバイス名" + ) args = parser.parse_args() @@ -302,7 +487,15 @@ def main(): # 動画を処理 try: - process_video(video_path, config, output_dir) + runtime = RuntimeSettings.from_env() + process_video( + video_path, + config, + output_dir, + use_wandb=args.wandb or runtime.use_wandb, + device_name=args.device_name or os.getenv("EXP_DEVICE_NAME", platform.node()), + runtime=runtime, + ) return 0 except KeyboardInterrupt: print("\n\nユーザーによる中断") diff --git a/raspi/line_detection/requirements.txt b/raspi/line_detection/requirements.txt index d3045759..105f995e 100644 --- a/raspi/line_detection/requirements.txt +++ b/raspi/line_detection/requirements.txt @@ -16,3 +16,6 @@ torchvision==0.17.2 # データ処理 pandas==2.2.1 + +# 実験管理(WANDB_MODE=offline対応) +wandb>=0.28.0 diff --git a/raspi/line_detection/result_output/event_logger.py b/raspi/line_detection/result_output/event_logger.py index d43a6252..e3a3ebee 100644 --- a/raspi/line_detection/result_output/event_logger.py +++ b/raspi/line_detection/result_output/event_logger.py @@ -8,7 +8,6 @@ import os from datetime import datetime from typing import List, Dict, Any -from detection.tracker import VehicleState class Event: @@ -104,7 +103,11 @@ def record_frame_time(self, processing_time_ms: float): self.total_processing_time_ms += processing_time_ms self.total_frames += 1 - def get_summary(self, tracker_summary: Dict) -> Dict[str, Any]: + def get_summary( + self, + tracker_summary: Dict, + timing_summary: Dict[str, Any] = None, + ) -> Dict[str, Any]: """ サマリーを取得 @@ -114,8 +117,13 @@ def get_summary(self, tracker_summary: Dict) -> Dict[str, Any]: Returns: Dict: サマリー情報 """ - avg_time = (self.total_processing_time_ms / self.total_frames - if self.total_frames > 0 else 0.0) + avg_time = ( + timing_summary["core_ms_mean"] + if timing_summary is not None + else self.total_processing_time_ms / self.total_frames + if self.total_frames > 0 + else 0.0 + ) return { "total_in": tracker_summary["total_in"], @@ -126,7 +134,14 @@ def get_summary(self, tracker_summary: Dict) -> Dict[str, Any]: "avg_processing_time_ms": round(avg_time, 2) } - def save_json(self, output_dir: str, tracker_summary: Dict) -> str: + def save_json( + self, + output_dir: str, + tracker_summary: Dict, + wandb_run_id: str = None, + exp_key: str = None, + timing_summary: Dict[str, Any] = None, + ) -> str: """ JSONファイルに保存 @@ -151,13 +166,22 @@ def save_json(self, output_dir: str, tracker_summary: Dict) -> str: "processed_at": self.start_time.isoformat(), "total_frames": self.total_frames, "avg_processing_time_ms": round( - self.total_processing_time_ms / self.total_frames - if self.total_frames > 0 else 0.0, + timing_summary["core_ms_mean"] + if timing_summary is not None + else self.total_processing_time_ms / self.total_frames + if self.total_frames > 0 + else 0.0, 2 ), "events": [event.to_dict() for event in self.events], - "summary": self.get_summary(tracker_summary) + "summary": self.get_summary(tracker_summary, timing_summary) } + if timing_summary is not None: + data["timing"] = timing_summary + if wandb_run_id is not None: + data["wandb_run_id"] = wandb_run_id + if exp_key is not None: + data["exp_key"] = exp_key # JSONファイルに書き込み with open(filepath, 'w', encoding='utf-8') as f: @@ -219,14 +243,18 @@ def save_csv(self, output_dir: str) -> str: print(f"✓ CSVログを保存: {filepath}") return filepath - def print_summary(self, tracker_summary: Dict): + def print_summary( + self, + tracker_summary: Dict, + timing_summary: Dict[str, Any] = None, + ): """ サマリーを出力 Args: tracker_summary: VehicleTrackerのサマリー """ - summary = self.get_summary(tracker_summary) + summary = self.get_summary(tracker_summary, timing_summary) print("\n" + "=" * 60) print("処理結果サマリー") diff --git a/raspi/line_detection/tests/__init__.py b/raspi/line_detection/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/raspi/line_detection/tests/test_event_logger.py b/raspi/line_detection/tests/test_event_logger.py new file mode 100644 index 00000000..3c53aec5 --- /dev/null +++ b/raspi/line_detection/tests/test_event_logger.py @@ -0,0 +1,53 @@ +import json +import sys +from pathlib import Path + + +LINE_ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(LINE_ROOT)) + +from result_output.event_logger import EventLogger + + +TRACKER_SUMMARY = { + "total_in": 2, + "total_out": 1, + "current_parked": 1, + "high_confidence_events": 2, + "normal_confidence_events": 1, +} + + +def test_save_json_includes_timing_and_wandb_reference(tmp_path): + logger = EventLogger(video_path="test.mp4") + logger.record_frame_time(100.0) + timing = { + "timing_schema_version": 2, + "warmup_frames": 30, + "measured_frames": 100, + "core_ms_mean": 20.0, + } + + path = logger.save_json( + str(tmp_path), + TRACKER_SUMMARY, + wandb_run_id="run-1", + exp_key="exp-1", + timing_summary=timing, + ) + data = json.loads(Path(path).read_text(encoding="utf-8")) + + assert data["avg_processing_time_ms"] == 20.0 + assert data["summary"]["avg_processing_time_ms"] == 20.0 + assert data["timing"] == timing + assert data["wandb_run_id"] == "run-1" + assert data["exp_key"] == "exp-1" + + +def test_save_json_keeps_wandb_keys_absent_when_disabled(tmp_path): + logger = EventLogger(video_path="test.mp4") + path = logger.save_json(str(tmp_path), TRACKER_SUMMARY) + data = json.loads(Path(path).read_text(encoding="utf-8")) + + assert "wandb_run_id" not in data + assert "exp_key" not in data diff --git a/uv.lock b/uv.lock index a7e1dd3c..798eb286 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,15 @@ resolution-markers = [ "(python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -96,6 +105,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -296,6 +326,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -958,6 +1012,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "polars" version = "1.41.2" @@ -986,6 +1049,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -1014,6 +1092,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1175,6 +1343,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] +[[package]] +name = "sentry-sdk" +version = "2.64.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -1193,6 +1374,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -1287,6 +1477,7 @@ dependencies = [ { name = "torch" }, { name = "torchvision" }, { name = "ultralytics" }, + { name = "wandb" }, ] [package.metadata] @@ -1299,6 +1490,7 @@ requires-dist = [ { name = "torch", specifier = ">=2.2.2" }, { name = "torchvision", specifier = ">=0.17.2" }, { name = "ultralytics", specifier = ">=8.2.52" }, + { name = "wandb", specifier = ">=0.28.0" }, ] [[package]] @@ -1325,6 +1517,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2026.2" @@ -1379,3 +1583,32 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "wandb" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "gitpython" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a7/683bfbd6cbade3012bc90d3e9c4cfc72dd62566195bf4c30321946d64b77/wandb-0.28.0.tar.gz", hash = "sha256:b20e5af0fe80e2e2a466b0466a1d60cedcc578dce0f036eca04f4a0adcad95b6", size = 40558332, upload-time = "2026-06-23T00:38:50.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/47/1723605f76c5d6446b6d0db65b83eda1599721bc8c1e65bd76cc1682b1a7/wandb-0.28.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:c3dab1205a5aca4abbad1eca08902cdba86add0edfa83d8d61b4429d0e79fa87", size = 24335272, upload-time = "2026-06-23T00:38:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/81/ff/42b539bc75bc48fc86981dccde89327ba9b71504b805b9ba42cba7c26de9/wandb-0.28.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:ae255da18726ee8e731ef82cbc85035b901a28ae14cf91604c361b44b8d44ce0", size = 25557959, upload-time = "2026-06-23T00:38:28.993Z" }, + { url = "https://files.pythonhosted.org/packages/15/55/c3db03d04aeab3726066a418b2ef6a1f8119774ee510f4fbe992f52b7472/wandb-0.28.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6dbcba12ab168aa37561f2f32dcdef8713495fc25fa7d30fdc9bfb37989694dd", size = 24878557, upload-time = "2026-06-23T00:38:31.417Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5d/1385ce3c219cb5bd30d4027687e3f8d25969c7dfd09adad1cbd5080e1a72/wandb-0.28.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:325b2d0bd88be6eda5db10542499bad3710927f2569c81a84dc5eeaffc76825c", size = 26764727, upload-time = "2026-06-23T00:38:33.775Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/23b6c17a6d3d5422b007707961c4496b2f6f892624d2910c9f7742fcc202/wandb-0.28.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8954bc1c62ae43914dce2bebfd1d9957f72350f8fbb78e5cdfe2ca9b6be8a7b8", size = 25051656, upload-time = "2026-06-23T00:38:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/89/67/9be00fb2db2281063af24a148636d2dd363d337317642ab5d8e93572c794/wandb-0.28.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9fec6c908554c2dad33110c1312bc3028cc2e430f0679f16b84f82c8ea801e3b", size = 27074113, upload-time = "2026-06-23T00:38:38.737Z" }, + { url = "https://files.pythonhosted.org/packages/59/b1/f7a96c09cab0c5131b1e6466659b093b401e1653cbe6bb77b462fc1c361d/wandb-0.28.0-py3-none-win32.whl", hash = "sha256:8834ef3a7c8c43b701654162783caa7ad37af48a0ff06fc35d0d65a411f76ccd", size = 24525206, upload-time = "2026-06-23T00:38:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c4/c7bed5e981679c74e9fbb22c03ff31c42e95f266199d03d8d325f4d0e6df/wandb-0.28.0-py3-none-win_amd64.whl", hash = "sha256:ac1f82292e2da4f98297b78c3a46726b3a6c5734ecb75fc39b8db2c8a4989159", size = 24525214, upload-time = "2026-06-23T00:38:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/b5ce9696c8cb955521a7941fbc443e78b2f504894c6ae1a2d0b1de6e12ae/wandb-0.28.0-py3-none-win_arm64.whl", hash = "sha256:c5b0faf1b84cf79ebabed77538c1940a4c6053e815f767a4004e877a1354bed1", size = 22378208, upload-time = "2026-06-23T00:38:47.148Z" }, +] From 59fafa64d8fdd633d9c4a91b008bdc8a1a3be053 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:13:28 +0900 Subject: [PATCH 28/57] =?UTF-8?q?feat:=20run=E8=AD=98=E5=88=A5=E5=AD=90?= =?UTF-8?q?=E3=81=A8=E5=86=8D=E7=8F=BEmanifest=E3=82=92=E5=85=B1=E9=80=9A?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/common/run_identity.py | 181 ++++++++++++++++++ raspi/common/tests/test_run_identity.py | 105 ++++++++++ raspi/common/tests/test_wandb_logger.py | 55 ++++++ raspi/common/wandb_logger.py | 26 ++- .../docs/wandb_integration_spec_v2.md | 66 ++++--- 5 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 raspi/common/run_identity.py create mode 100644 raspi/common/tests/test_run_identity.py diff --git a/raspi/common/run_identity.py b/raspi/common/run_identity.py new file mode 100644 index 00000000..bd80aca0 --- /dev/null +++ b/raspi/common/run_identity.py @@ -0,0 +1,181 @@ +"""実験runの条件識別子・実行識別子・再現情報・manifestを共通管理する。""" + +from __future__ import annotations + +import hashlib +import importlib.metadata +import json +import platform +import subprocess +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence + + +CONDITION_SCHEMA_VERSION = 1 +MANIFEST_SCHEMA_VERSION = 1 +REPRODUCIBILITY_PACKAGES = ( + "numpy", + "opencv-python", + "pandas", + "torch", + "ultralytics", + "wandb", +) + + +def _validate_mapping_keys(value: Any) -> None: + if isinstance(value, Mapping): + if not all(isinstance(key, str) for key in value): + raise TypeError("condition keys must be strings") + for nested in value.values(): + _validate_mapping_keys(nested) + elif isinstance(value, (list, tuple)): + for nested in value: + _validate_mapping_keys(nested) + + +def canonical_condition_json(condition: Mapping[str, Any]) -> str: + """JSONの型を保ったまま、辞書順に依存しないcanonical表現を返す。""" + + if not isinstance(condition, Mapping): + raise TypeError("condition must be a mapping") + _validate_mapping_keys(condition) + return json.dumps( + { + "schema_version": CONDITION_SCHEMA_VERSION, + "condition": condition, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def build_condition_key(condition: Mapping[str, Any]) -> str: + """同じ条件なら同じ値になる、型付きcanonical JSON由来のキーを返す。""" + + payload = canonical_condition_json(condition).encode("utf-8") + return f"ck{CONDITION_SCHEMA_VERSION}_{hashlib.sha256(payload).hexdigest()}" + + +def new_execution_id() -> str: + """個々の実行を一意に識別するUUIDを返す。""" + + return str(uuid.uuid4()) + + +def build_display_name( + logic_name: str, + dataset: str, + params: Mapping[str, Any] | None = None, +) -> str: + """W&B UI等で使う、人が読めるが一意性を担わない表示名を返す。""" + + parts = [logic_name, dataset] + if params: + parts.extend(f"{key}={params[key]}" for key in sorted(params)) + return "__".join(parts) + + +def build_run_identity( + condition: Mapping[str, Any], + *, + display_name: str, + execution_id: str | None = None, +) -> dict[str, str]: + """1 run分の条件キー・実行ID・表示名をまとめて返す。""" + + return { + "condition_key": build_condition_key(condition), + "execution_id": execution_id or new_execution_id(), + "display_name": display_name, + } + + +def _run_git(repo_root: Path, *args: str) -> str | None: + try: + completed = subprocess.run( + ["git", "-C", str(repo_root), *args], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + return completed.stdout.rstrip("\n") + + +def collect_reproducibility_info(repo_root: Path | None = None) -> dict[str, Any]: + """Git状態と主要ライブラリ版をW&B configへ保存できる形で返す。""" + + root = Path(repo_root) if repo_root is not None else Path(__file__).resolve().parents[2] + git_sha = _run_git(root, "rev-parse", "HEAD") + git_status = _run_git(root, "status", "--porcelain", "--untracked-files=normal") + git_diff = _run_git(root, "diff", "--binary", "HEAD") + dirty = bool(git_status) if git_status is not None else None + dirty_fingerprint = None + if dirty: + fingerprint_source = f"{git_status}\n{git_diff or ''}".encode("utf-8") + dirty_fingerprint = hashlib.sha256(fingerprint_source).hexdigest() + + library_versions: dict[str, str | None] = {} + for package in REPRODUCIBILITY_PACKAGES: + try: + library_versions[package] = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: + library_versions[package] = None + + return { + "git_sha": git_sha, + "git_dirty": dirty, + "git_dirty_fingerprint": dirty_fingerprint, + "python_version": platform.python_version(), + "library_versions": library_versions, + } + + +def write_run_manifest( + manifest_path: Path, + *, + config: Mapping[str, Any], + output_dir: Path, + output_paths: Sequence[Path | str], + wandb_run_id: str | None, +) -> dict[str, Any]: + """runとローカル出力を相互参照できるmanifestをJSONで保存する。""" + + required = ("condition_key", "execution_id", "display_name") + missing = [key for key in required if not config.get(key)] + if missing: + raise ValueError(f"config is missing run identity fields: {', '.join(missing)}") + + resolved_output_dir = Path(output_dir).resolve() + resolved_outputs = [] + for output_path in output_paths: + path = Path(output_path) + if not path.is_absolute(): + path = resolved_output_dir / path + resolved_outputs.append(str(path.resolve())) + + manifest = { + "schema_version": MANIFEST_SCHEMA_VERSION, + "created_at_utc": datetime.now(timezone.utc).isoformat(), + "condition_key": config["condition_key"], + "execution_id": config["execution_id"], + "wandb_run_id": wandb_run_id, + "display_name": config["display_name"], + "output_dir": str(resolved_output_dir), + "output_paths": resolved_outputs, + "config": dict(config), + } + target = Path(manifest_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2, allow_nan=False) + "\n", + encoding="utf-8", + ) + return manifest diff --git a/raspi/common/tests/test_run_identity.py b/raspi/common/tests/test_run_identity.py new file mode 100644 index 00000000..cbc4fc36 --- /dev/null +++ b/raspi/common/tests/test_run_identity.py @@ -0,0 +1,105 @@ +import json +from pathlib import Path + +import pytest + +from common.run_identity import ( + build_condition_key, + build_run_identity, + canonical_condition_json, + write_run_manifest, +) + + +BASE_CONDITION = { + "logic_name": "roi_counter", + "input_sha256": "video-a", + "model_sha256": "model-a", + "roi_points": [[0, 0], [10, 0], [10, 10], [0, 10]], + "s_low": 0.25, + "s_high": 0.75, +} + + +def test_condition_key_is_insertion_order_independent(): + first = {"a": 1, "b": {"x": True, "y": [1, 2]}} + second = {"b": {"y": [1, 2], "x": True}, "a": 1} + assert build_condition_key(first) == build_condition_key(second) + + +def test_condition_key_preserves_json_value_types(): + assert build_condition_key({"value": 1}) != build_condition_key({"value": "1"}) + assert build_condition_key({"value": 1}) != build_condition_key({"value": 1.0}) + assert build_condition_key({"value": True}) != build_condition_key({"value": 1}) + + +def test_condition_key_rejects_non_finite_float(): + with pytest.raises(ValueError): + canonical_condition_json({"value": float("nan")}) + + +def test_condition_key_rejects_non_string_nested_keys(): + with pytest.raises(TypeError, match="keys must be strings"): + canonical_condition_json({"nested": {1: "value"}}) + + +@pytest.mark.parametrize( + ("field", "different_value"), + [ + ("input_sha256", "video-b"), + ("model_sha256", "model-b"), + ("roi_points", [[1, 0], [10, 0], [10, 10], [0, 10]]), + ], +) +def test_video_model_and_roi_changes_produce_different_condition_keys( + field, different_value +): + changed = {**BASE_CONDITION, field: different_value} + assert build_condition_key(BASE_CONDITION) != build_condition_key(changed) + + +def test_same_condition_has_same_key_but_different_execution_ids(): + first = build_run_identity(BASE_CONDITION, display_name="run") + second = build_run_identity(BASE_CONDITION, display_name="run") + assert first["condition_key"] == second["condition_key"] + assert first["execution_id"] != second["execution_id"] + + +def test_write_run_manifest_records_identity_and_outputs(tmp_path: Path): + identity = build_run_identity( + BASE_CONDITION, + display_name="roi_counter__video-a__s_low=0.25", + execution_id="execution-1", + ) + config = {**BASE_CONDITION, **identity, "exp_key": identity["condition_key"]} + + manifest_path = tmp_path / "manifests" / "execution-1.json" + manifest = write_run_manifest( + manifest_path, + config=config, + output_dir=tmp_path, + output_paths=["result.json", tmp_path / "frames.csv"], + wandb_run_id="wandb-1", + ) + + saved = json.loads(manifest_path.read_text(encoding="utf-8")) + assert saved == manifest + assert saved["condition_key"] == identity["condition_key"] + assert saved["execution_id"] == "execution-1" + assert saved["wandb_run_id"] == "wandb-1" + assert saved["output_dir"] == str(tmp_path.resolve()) + assert saved["output_paths"] == [ + str((tmp_path / "result.json").resolve()), + str((tmp_path / "frames.csv").resolve()), + ] + + +def test_write_run_manifest_requires_identity(tmp_path: Path): + with pytest.raises(ValueError, match="condition_key"): + write_run_manifest( + tmp_path / "manifest.json", + config={}, + output_dir=tmp_path, + output_paths=[], + wandb_run_id=None, + ) diff --git a/raspi/common/tests/test_wandb_logger.py b/raspi/common/tests/test_wandb_logger.py index e140a02b..52a13420 100644 --- a/raspi/common/tests/test_wandb_logger.py +++ b/raspi/common/tests/test_wandb_logger.py @@ -1,4 +1,7 @@ +import json +import sys from pathlib import Path +from types import SimpleNamespace import pytest @@ -56,6 +59,58 @@ def test_disabled_logger_is_noop(tmp_path: Path): assert not (tmp_path / "wandb_run_id.txt").exists() +def test_enabled_logger_uses_display_name_and_persists_all_ids( + tmp_path: Path, monkeypatch +): + run = SimpleNamespace(id="wandb-1", summary={}) + calls = {} + + def fake_init(**kwargs): + calls["init"] = kwargs + return run + + fake_wandb = SimpleNamespace( + init=fake_init, + define_metric=lambda *args, **kwargs: None, + log=lambda *args, **kwargs: None, + finish=lambda **kwargs: calls.update(finish=kwargs), + ) + monkeypatch.setitem(sys.modules, "wandb", fake_wandb) + config = { + "condition_key": "condition-1", + "execution_id": "execution-1", + "display_name": "readable-run", + "exp_key": "condition-1", + } + logger = ExperimentLogger( + project="project", + config=config, + group="group", + job_type="speed_eval", + tags=["test"], + enabled=True, + ) + + assert calls["init"]["name"] == "readable-run" + assert calls["init"]["config"] == config + logger.save_run_id(tmp_path) + saved_ids = (tmp_path / "wandb_run_id.txt").read_text(encoding="utf-8") + assert "wandb_run_id=wandb-1" in saved_ids + assert "execution_id=execution-1" in saved_ids + assert "condition_key=condition-1" in saved_ids + + result_path = tmp_path / "result.json" + result_path.write_text("{}", encoding="utf-8") + logger.append_to_result_json(result_path) + result = json.loads(result_path.read_text(encoding="utf-8")) + assert result == { + "wandb_run_id": "wandb-1", + "execution_id": "execution-1", + "condition_key": "condition-1", + "exp_key": "condition-1", + } + + def test_validate_log_interval_sec_accepts_positive(): assert validate_log_interval_sec("5") == 5.0 assert validate_log_interval_sec(0.1) == 0.1 diff --git a/raspi/common/wandb_logger.py b/raspi/common/wandb_logger.py index b0b16dc0..3bebb127 100644 --- a/raspi/common/wandb_logger.py +++ b/raspi/common/wandb_logger.py @@ -5,7 +5,8 @@ → wandb 未インストール環境・CI・オフライン実行で既存挙動と完全一致する。 - WANDB_MODE(online/offline)は wandb 側の標準挙動に委譲(本ラッパで上書きしない)。 オフライン計測 → 後日 `wandb sync ` でアップロードできる。 -- `build_exp_key()` は両ロジック・後追い評価スクリプトが必ず共有する突合キー生成関数。 +- 新規runの識別子は `common.run_identity` で生成する。 +- `build_exp_key()` は過去データとの互換用にのみ残している。 """ import json @@ -31,13 +32,14 @@ def build_exp_key(logic_name: str, dataset: str, device_name: str, params: Dict[str, Any]) -> str: """ - run を一意に再特定するための複合キーを生成する(全スクリプト共通)。 + 旧形式の複合キーを生成する(過去データとの互換用)。 形式: f"{logic_name}__{dataset}__{param_str}__{device_name}" param_str は params をキー名の昇順ソートで "k1=v1_k2=v2" に正規化する (float は f"{v:g}")。 - 各スクリプトで独自フォーマットを組み立ててはならない(突合キーとして機能しなくなるため)。 + 新規runでは `build_condition_key()` を使い、config.exp_key は + config.condition_key のaliasとして保存する。 """ parts = [] for key in sorted(params.keys()): @@ -96,7 +98,7 @@ def __init__( """ Args: project: W&B プロジェクト名 - config: run 開始時に固定するスカラ設定(exp_key を含む) + config: run 開始時に固定する設定(condition_key / execution_id を含む) group: run のグループ(= logic_name) job_type: "speed_eval" / "accuracy_eval" 等 tags: device_name, input_type 等 @@ -106,6 +108,8 @@ def __init__( self._wandb = None self._run = None self._exp_key = config.get("exp_key") if config else None + self._condition_key = config.get("condition_key") if config else None + self._execution_id = config.get("execution_id") if config else None if not self.enabled: return @@ -120,6 +124,7 @@ def __init__( group=group, job_type=job_type, tags=tags, + name=config.get("display_name"), ) @property @@ -178,16 +183,21 @@ def finish(self, exit_code: int = 0) -> None: self._wandb.finish(exit_code=exit_code) def save_run_id(self, out_dir: Path) -> None: - """out_dir/"wandb_run_id.txt" に run.id と exp_key を書き出す。""" + """out_dir/"wandb_run_id.txt" にW&Bとローカルの識別子を書き出す。""" if not self.enabled: return out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) - text = f"wandb_run_id={self.run_id}\nexp_key={self._exp_key}\n" + text = ( + f"wandb_run_id={self.run_id}\n" + f"execution_id={self._execution_id}\n" + f"condition_key={self._condition_key}\n" + f"exp_key={self._exp_key}\n" + ) (out_dir / "wandb_run_id.txt").write_text(text, encoding="utf-8") def append_to_result_json(self, result_path: Path) -> None: - """既存の result.json へ wandb_run_id / exp_key を追記する。 + """既存の result.json へW&Bとローカルの識別子を追記する。 ファイルが無い / 無効時は何もしない(後方互換)。 """ @@ -198,6 +208,8 @@ def append_to_result_json(self, result_path: Path) -> None: return data = json.loads(result_path.read_text(encoding="utf-8")) data["wandb_run_id"] = self.run_id + data["execution_id"] = self._execution_id + data["condition_key"] = self._condition_key data["exp_key"] = self._exp_key result_path.write_text( json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" diff --git a/raspi/line_detection/docs/wandb_integration_spec_v2.md b/raspi/line_detection/docs/wandb_integration_spec_v2.md index 11a2d6d1..8441a2f2 100644 --- a/raspi/line_detection/docs/wandb_integration_spec_v2.md +++ b/raspi/line_detection/docs/wandb_integration_spec_v2.md @@ -1,7 +1,7 @@ # 実験管理機能(Weights & Biases 連携)実装指示書 v2 > v1 からの主な変更: オフラインモード対応(§1-8, §6)、スイープ時のトラッカー状態リセット(§4.2)、 -> フレームログのダウンサンプリング(§2.3)、異常終了時の finish 保証(§3.1)、`exp_key` 生成規則の一元化(§3.1)、 +> フレームログのダウンサンプリング(§2.3)、異常終了時の finish 保証(§3.1)、run識別規則の一元化(§3.1a)、 > 共通モジュールの import 経路指定(§3.3)、W&B Artifact のオプション項目追加(§10)。 > > このドキュメントは git 管理下の正式な契約書である(Issue #102 にて .gitignore の docs/ 一括除外に例外を追加し追跡開始)。 @@ -28,9 +28,11 @@ 2. **比較したくなりそうな軸は、値が未定でも config キーとして必ず用意する**(未使用なら既定値か `None`)。config に無い軸は後から比較できない。 3. **精度系の summary キーは、データが無くても `None` で先に確保する**。後追いスクリプトが同じスキーマへ書き込めるようにするため。 - 注意: W&B のテーブル UI では値が全 run で `None` の間は列が表示されないことがあるが、キー自体は保存されるので問題ない。後追い書き込み後に列が現れる。 -4. **run を一意に再特定できる手段を 2 重で持たせる。** - - `run.id` を出力ディレクトリ内のファイルに保存する。 - - `config` に複合キー `exp_key`(生成規則は §3.1 で一元化)を必ず埋め、run.id を取りこぼしても突合できるようにする。 +4. **runの条件と個々の実行を別の識別子で管理する。** + - `condition_key`: 結果へ影響する条件の型付きcanonical JSONから生成するhash。同条件の再実行では同じ値。 + - `execution_id`: 実行ごとに生成するUUID。同条件の再実行でも異なる値。 + - `wandb_run_id`: W&Bが発行するID。ローカルmanifestにも保存する。 + - `exp_key`は移行期間中のみ`condition_key`のaliasとして残す。 5. **時系列メトリクスは後から遡及追加できない**。最初に `wandb.log` する辞書へ余分めにキーを用意しておく。スカラ(summary / config)は後付け可。 6. 既存スクリプトの**コア処理ロジック(検出・カウント・MAE 算出)は原則変更しない**。W&B 連携は addon として差し込む。 - **唯一の例外**: §4.2 のトラッカー状態リセット。これは実験の独立性(run 比較の妥当性)に直結するため修正を許可する。 @@ -56,6 +58,8 @@ | `device_name` | str | 処理デバイス名(例 `"raspi5"`, `"macbook_m1"`) | 環境変数 `EXP_DEVICE_NAME`(未設定なら `platform.node()` をフォールバック) | | `device_accelerator` | str | `"cpu"` / `"cuda"` / `"coral"` 等 | 環境変数 or 自動判定 | | `model_path` | str | 使用した重みファイル名 | 各スクリプトのモデル指定 | +| `input_sha256` / `model_sha256` | str | 入力内容とモデル重みのhash | 共通hash関数 | +| `roi_points` | list | ROIの全頂点座標 | roi-counter | | `frame_width` | int | 入力解像度 幅 | `cv2.CAP_PROP_FRAME_WIDTH` | | `frame_height` | int | 入力解像度 高さ | `cv2.CAP_PROP_FRAME_HEIGHT` | | `source_fps` | float | 入力動画の FPS | `cv2.CAP_PROP_FPS` | @@ -66,11 +70,19 @@ | `yolo_conf` | float | confidence_threshold | `model.track()` に実際に渡した値と完全一致させる(記録専用の別定数を持たせてはならない) | | `yolo_iou` | float | iou_threshold | `model.track()` に実際に渡した値と完全一致させる(記録専用の別定数を持たせてはならない) | | `yolo_device` | str/None | model.track() に渡す device 指定 | 環境変数 `YOLO_DEVICE`(既定 None=Ultralytics 自動選択) | +| `yolo_imgsz` / `tracker_config` | int/str | 実際の入力サイズとtracker設定 | `model.track()` 引数 | | **ロジック別パラメータ** | | | | | `s_low` / `s_high` | float | roi-counter の閾値 | roi-counter のみ | | `margin` / `max_frame_gap` / `cleanup_threshold` | num | line_detection のパラメータ | line_detection のみ | -| **複合キー(突合用・必須)** | | | | -| `exp_key` | str | §3.1 の `build_exp_key()` で生成 | 共通ユーティリティ | +| `line1_points` / `line2_points` | list | 各ラインの始点・終点 | line_detection のみ | +| `parking_reference_point` | list | 駐車場側を決める基準点 | line_detection のみ | +| **run識別・再現情報(必須)** | | | | +| `condition_key` | str | `build_condition_key()`で生成する条件hash | `common.run_identity` | +| `execution_id` | str | 実行ごとのUUID | `common.run_identity` | +| `display_name` | str | UI向け可読名(一意性には使わない) | `common.run_identity` | +| `git_sha` / `git_dirty` | str/bool | 実行コードの版とdirty状態 | `common.run_identity` | +| `library_versions` | dict | Pythonと主要ライブラリ版 | `common.run_identity` | +| `exp_key` | str | `condition_key`の後方互換alias | 共通ユーティリティ | ### 2.2 summary(run 終了時のスカラ) @@ -205,12 +217,9 @@ W&B では `comparison_key` が一致する ROI / 2 ライン run のみを直 - `set_summary(self, key: str, value)` / `set_summaries(self, d: dict)`。 - `finish(self, exit_code: int = 0)` … `wandb.finish(exit_code=exit_code)`。 - **`run_id` プロパティ** … `wandb.run.id`(無効時は `None`)。 -- `save_run_id(self, out_dir: Path)` … `out_dir / "wandb_run_id.txt"` に run.id と exp_key を書き出す。既存の `result.json` がある場合はそこへ `wandb_run_id` / `exp_key` を追記できるヘルパも用意。 +- `save_run_id(self, out_dir: Path)` … `out_dir / "wandb_run_id.txt"`にwandb_run_id、execution_id、condition_keyと互換aliasを保存する。 - `init_accuracy_placeholders(self)` … §2.2 の精度系キーを `None` で summary に設定。init 直後に呼ぶ。 -- **`build_exp_key(logic_name: str, dataset: str, device_name: str, params: dict) -> str`(モジュール関数)** - - 形式: `f"{logic_name}__{dataset}__{param_str}__{device_name}"` - - `param_str` は `params` を **キー名の昇順ソート**で `"k1=v1_k2=v2"` に正規化する(float は `f"{v:g}"`)。 - - この関数を両ロジック・後追い評価スクリプトの**全員が共通で使う**こと。各スクリプトで独自フォーマットを組み立ててはならない(突合キーとして機能しなくなるため)。 +- 旧`build_exp_key()`は過去runの読取互換用にのみ残す。新規configでは`exp_key = condition_key`とする。 - `validate_log_interval_sec(value: float, source: str = "LOG_INTERVAL_SEC") -> float`(モジュール関数)… 0 以下・NaN・inf・不正文字列を拒否し `ValueError` を送出する。起動時に呼ぶこと。 - `next_log_boundary(next_log_sec, t_rel_sec, log_interval_sec) -> float` / `should_log_frame(t_rel_sec, next_log_sec, count_changed) -> bool`(モジュール関数)… 定期サンプリング境界の判定・進行を while ループなしで行う。 @@ -219,6 +228,13 @@ W&B では `comparison_key` が一致する ROI / 2 ライン run のみを直 - **呼び出し側は `finish()` を必ず `try/finally` で保証する**(§4 各所に明記)。カメラ切断・例外・Ctrl+C でも run が "running" のまま放置されないこと。異常終了時は `exit_code=1` で finish する。 - 長時間のカメラ運用向けに `update_running_summary(self, d: dict)`(処理途中の count_in/out 等を summary へ随時反映するだけの薄いメソッド)を用意する。クラッシュしても直近の集計が残る。 +### 3.1a run識別ユーティリティ `raspi/common/run_identity.py` + +- `build_condition_key(condition)`は型付きcanonical JSONをSHA-256化する。floatの表示丸めや辞書挿入順に依存しない。 +- `build_run_identity()`は同一条件でも毎回異なる`execution_id`を生成する。 +- `collect_reproducibility_info()`はGit SHA、dirty状態、Python・主要ライブラリ版を返す。 +- `write_run_manifest()`はW&Bの有効・無効に関係なく、ID、config、出力パスをJSONへ保存する。 + ### 3.2 統計ユーティリティ `raspi/common/frame_stats.py`(新規作成) - `compute_frame_stats(frame_ms_list: list[float], source_fps: float) -> dict` @@ -236,7 +252,8 @@ W&B では `comparison_key` が一致する ROI / 2 ライン run のみを直 # 例: raspi/roi-counter/scripts/02_run_analysis.py の場合 sys.path.insert(0, str(Path(__file__).parents[1])) # 既存: roi-counter/ sys.path.insert(0, str(Path(__file__).parents[2])) # 追加: raspi/ - from common.wandb_logger import ExperimentLogger, build_exp_key + from common.wandb_logger import ExperimentLogger + from common.run_identity import build_condition_key, build_run_identity from common.frame_stats import compute_frame_stats ``` - `line_detection/main.py` は `os.path.dirname` スタイルなのでそれに合わせて親ディレクトリを追加する。 @@ -250,11 +267,11 @@ W&B では `comparison_key` が一致する ROI / 2 ライン run のみを直 - 冒頭パラメータ群の近くに W&B 設定(`USE_WANDB`, `WANDB_PROJECT`, `EXP_DEVICE_NAME`, `LOG_INTERVAL_SEC` 等)を追加。環境変数で上書き可能にする。 - `main()` 内: 1. 既存の `out_dir` 作成後、`config` dict を構築(§2.1 に従う。`logic_name="roi_counter"`, `s_low/s_high` を含める)。 - 2. `build_exp_key()` で `exp_key` を生成し config に入れる。 + 2. `build_run_identity()`で`condition_key`、`execution_id`、`display_name`を生成しconfigへ入れる。 3. `ExperimentLogger` を初期化し、`init_accuracy_placeholders()` を呼ぶ。**以降の処理全体を `try/finally` で包み、finally で `finish()`**(例外時は `exit_code=1`)。 4. フレームループ内、既存の `frame_records.append(...)` の直後に、間引き条件(§2.3)を満たすフレームで `logger.log_frame(step=frame_idx, metrics={...})` を追加(`frame_ms`, `net_flow`, `cumulative_in`, `cumulative_out`, `num_tracks`, `retained_states`)。カウント変化時は間引き中でも log。 5. ループ後、`compute_frame_stats()` の結果と count 系を `set_summaries`。GT があれば `count_error` も。 - 6. `logger.save_run_id(out_dir)` と `result.json` への `wandb_run_id` / `exp_key` 追記。 + 6. `logger.save_run_id(out_dir)`と`run_manifest.json`を保存する。 - **既存の CSV / JSON / mp4 出力は一切削らない**。 ### 4.2 `roi-counter/scripts/04_multi_video_mae.py`(スイープ) @@ -269,18 +286,21 @@ W&B では `comparison_key` が一致する ROI / 2 ライン run のみを直 - どちらを行ったかに関わらず `config["tracker_reset"] = True` を記録する。リセットできなかった場合は `False` を記録し WARN を出す。 - 同じ問題は `03_sweep_params.py` にもあるが、本実装のスコープは 04 のみとし、03 は TODO コメントを残す。 - `main()` のループ内、`run_once` の後で run を 1 つ作り、config・summary(速度統計 + count + count_error + gt)・精度プレースホルダを記録して finish(try/finally)。 -- `detail_rows`(既存 CSV)に `wandb_run_id` / `exp_key` 列を追加して CSV と W&B を相互参照可能にする。 +- `detail_rows`(既存CSV)に`wandb_run_id`、`execution_id`、`condition_key`列を追加してCSVとW&Bを相互参照可能にする。 - 既存の `results.csv` / `mae_summary.csv` 出力は維持する。 ### 4.3 `line_detection/main.py` - `process_video()` に W&B 連携を追加。 -- `config` は `logic_name="line_detection"`, `margin`, `max_frame_gap`, `cleanup_threshold`, `yolo_conf`, `yolo_iou`, `model_path`, 解像度, fps 等。 +- `config`は`logic_name="line_detection"`、Line1・Line2の全座標、駐車場基準点、 + `margin`、`max_frame_gap`、`cleanup_threshold`、YOLO実引数、入力・モデルhash、 + Git・主要ライブラリ版を含める。 - **処理時間の生リストは `process_video()` 内のローカルリストで保持する**(`EventLogger` クラスは変更しない。processing_time_ms を計算している箇所で同じ値を append するだけ)。 - フレームループで間引き付き `log_frame`。`net_flow` は `tracker.get_net_flow()` 相当の値(`count_in - count_out`)、`num_tracks` はそのフレームで新たにIDが割り当てられた検出数、`retained_states` は `len(tracker.states)`(保持中の状態数、num_tracksとは別概念)。カウント確定時(`mark_as_counted` が呼ばれたフレーム)は間引き中でも log。 - カメラ入力(`--camera`)は長時間運用になるため、`update_running_summary()` を N フレームごと(例: 300 フレーム)に呼び、クラッシュ時にも直近カウントが summary に残るようにする。 - ループ後、`tracker.get_summary()` とローカル処理時間リストから summary を構築。全体を try/finally で包み finish を保証(既存の KeyboardInterrupt ハンドリングと整合させる)。 -- 既存の `EventLogger`(JSON/CSV 出力)は維持。`events_*.json` に `wandb_run_id` / `exp_key` を含める(`EventLogger.save_json` の data dict へ引数追加で渡す。デフォルト `None` にして後方互換を保つ)。 +- 既存の`EventLogger`(JSON/CSV出力)は維持。`events_*.json`に`wandb_run_id`、 + `execution_id`、`condition_key`と互換aliasを含め、run manifestも保存する。 - `argparse` に `--wandb` / `--device-name` を追加してよい。 --- @@ -291,13 +311,13 @@ SAM3 の GT が揃った後に実行する独立スクリプト。**run の再 要件: -- 入力: SAM3 由来の評価結果テーブル(per-run の accuracy / precision / recall / f1 / tp/fp/fn/tn と、対応する `exp_key` または `wandb_run_id`。CSV か JSON)。 +- 入力: SAM3由来の評価結果テーブルと、対応する`wandb_run_id`、`execution_id`、`condition_key`のいずれか(旧データのみ`exp_key`も可)。 - 処理: 1. `wandb.Api()` を使う。 - 2. 各評価行について、`wandb_run_id` があればそれで run を直接取得。無ければ project 内の run を `config.exp_key` 一致で検索(`api.runs(path, filters={"config.exp_key": key})` を使う。全走査は避ける)。 + 2. `wandb_run_id`、`execution_id`、`condition_key`、旧`exp_key`の優先順でrunを特定する。config検索はfilterを使い全走査しない。 3. `run.summary["accuracy"] = ...` 等を設定し、`run.summary.update()`。 4. `eval_unit` / `gt_source="sam3"` も記録。 - 5. 一致する run が 0 件 / 複数件の場合は WARN を出してスキップ(複数件は最新 run を選ぶのではなく人間に判断させる)。 + 5. 0件はWARNしてスキップする。複数件は候補ID・名前を表示し、誤更新を避けるため処理全体を停止する。 - **GT の定義を明示するコメントを必ず書く**: - 台数の最終 GT は人手の真値(既存 `*_gt.json`)を ground truth とする。 - SAM3 は per-vehicle 軌跡 / イベント列の生成補助に使い、SAM3 出力をそのまま GT としない。 @@ -320,14 +340,14 @@ SAM3 の GT が揃った後に実行する独立スクリプト。**run の再 1. `USE_WANDB=false` で全スクリプトが従来どおり動作し、出力ファイルが変わらない(`wandb` 未インストールでも動く)。 2. `USE_WANDB=true` で各 run に config / summary / time-series が記録される。 -3. 全 run の config に `exp_key` が入り、`build_exp_key()` が全スクリプトで共有されている。出力ディレクトリに `wandb_run_id` が保存される。 +3. 全runのconfigに`condition_key`、`execution_id`、`display_name`が入り、manifestにW&B IDと出力パスが保存される。同条件の再実行はcondition_keyが同じでexecution_idが異なる。 4. summary に精度系キーが `None` で存在する。 5. `update_accuracy_from_sam3.py --dry-run` が、ダミー評価テーブルを入力に対象 run を正しく特定・表示できる。dry-run なしで summary 更新が動作する。 6. `frame_ms_p95` / `effective_fps` / `realtime_ok` が正しく算出される(`compute_frame_stats` の単体テストで担保)。 7. 例外・KeyboardInterrupt で中断しても run が finish される(手動確認でよい)。 8. `04_multi_video_mae.py` で run 間のトラッカー状態リセットが行われ、`tracker_reset` が config に記録される。 9. `LOG_INTERVAL_SEC` の間引きが機能し、カウント変化フレームは間引き中でも log される。 -10. 既存テスト(`roi-counter/tests/`)が引き続き通る。`raspi/common/tests/` に `frame_stats` と `ExperimentLogger`(enabled=False の no-op、build_exp_key の正規化)の最小テストを追加する。 +10. 既存テストが通り、canonical key、ROI・Line1・Line2・駐車場基準点・モデル・動画差分、execution_id、manifest保存の単体テストがある。 --- @@ -340,7 +360,7 @@ SAM3 の GT が揃った後に実行する独立スクリプト。**run の再 ## 9. 実装順序の推奨 -1. `common/frame_stats.py` と `common/wandb_logger.py`(no-op 経路・build_exp_key・テスト含む)。 +1. `common/frame_stats.py`、`common/wandb_logger.py`、`common/run_identity.py`(no-op経路・識別子・manifestテスト含む)。 2. `roi-counter/scripts/02_run_analysis.py` への差し込み(最小構成の検証)。 3. `roi-counter/scripts/04_multi_video_mae.py`(スイープ → 複数 run、トラッカーリセット含む)。 4. `line_detection/main.py`。 From 1e8fe52b1ae454c50d32563ea1a76cc108f1c206 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:17:08 +0900 Subject: [PATCH 29/57] =?UTF-8?q?feat:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E5=AE=9F=E9=A8=93=E3=81=B8run=E8=AD=98=E5=88=A5=E6=83=85?= =?UTF-8?q?=E5=A0=B1=E3=82=92=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/README.md | 16 ++++ raspi/line_detection/main.py | 96 +++++++++++++++++-- .../result_output/event_logger.py | 10 ++ .../line_detection/tests/test_event_logger.py | 10 +- .../tests/test_run_identity_integration.py | 66 +++++++++++++ 5 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 raspi/line_detection/tests/test_run_identity_integration.py diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md index c6b3f30a..c53bef1a 100644 --- a/raspi/line_detection/README.md +++ b/raspi/line_detection/README.md @@ -143,12 +143,26 @@ WANDB_MODE=offline python main.py --input data/inputs/test.mp4 \ 直接比較する。W&B送信は計測区間外で行うため、online/offlineの通信状態は 速度値に含まれない。offline runは後日次のように同期する。 +実験runの識別には、用途を分けた次の値を使用する。 + +- `condition_key`: Line1・Line2・駐車場基準点、検知パラメータ、入力・モデルhash、 + Git・主要ライブラリ版などの型付きcanonical JSONから生成する条件hash +- `execution_id`: 同条件の再実行も区別する実行ごとのUUID +- `wandb_run_id`: W&Bが発行するID(W&B有効時のみ) +- `display_name`: W&B UI向けの可読名。一意性の判定には使用しない + +旧`exp_key`は移行期間中のみ`condition_key`のaliasとして残す。 + ```bash wandb sync ``` ## 出力ファイル +W&Bの有効・無効にかかわらず、runごとに +`data/outputs/manifests/{execution_id}.json`を保存する。manifestには上記ID、 +完全な実験config、イベントログ・CSV・動画の絶対パスを記録する。 + ### アノテーション動画 (`data/outputs/videos/`) 元の動画に以下の情報を重ねて表示: @@ -164,6 +178,8 @@ wandb sync { "video_path": "data/inputs/test.mp4", "processed_at": "2026-06-24T10:30:00", + "execution_id": "d45174ea-4de7-4bac-9d44-1ab9c86bd07a", + "condition_key": "ck1_...", "total_frames": 900, "avg_processing_time_ms": 48.5, "events": [ diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index 0373126b..232196e6 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -32,16 +32,22 @@ elapsed_timer, model_synchronizer, require_measured_timings, + resolve_model_device, sha256_file, validate_warmup_frames, ) from common.wandb_logger import ( ExperimentLogger, - build_exp_key, next_log_boundary, should_log_frame, validate_log_interval_sec, ) +from common.run_identity import ( + build_display_name, + build_run_identity, + collect_reproducibility_info, + write_run_manifest, +) WEBCAM_FPS = 30.0 @@ -76,6 +82,51 @@ def from_env(cls) -> "RuntimeSettings": ) +LINE_CONDITION_KEYS = ( + "logic_name", + "input_type", + "input_sha256", + "model_sha256", + "line1_points", + "line2_points", + "parking_reference_point", + "vehicle_classes", + "method", + "margin", + "max_frame_gap", + "cleanup_threshold", + "tracker_reset", + "log_interval_sec", + "yolo_conf", + "yolo_iou", + "yolo_device", + "yolo_imgsz", + "tracker_config", + "tracker_config_sha256", + "device_name", + "device_accelerator", + "frame_width", + "frame_height", + "source_fps", + "warmup_frames", + "save_video", + "save_logs", + "show_display", + "timing_schema_version", + "git_sha", + "git_dirty", + "git_dirty_fingerprint", + "python_version", + "library_versions", +) + + +def build_line_condition(run_config: dict) -> dict: + """2ライン方式の結果・記録へ影響する条件だけを抽出する。""" + + return {key: run_config.get(key) for key in LINE_CONDITION_KEYS} + + def process_video( video_path: str | int, config: Config, @@ -154,6 +205,7 @@ def process_video( # 出力動画ライターを初期化 out = None + output_video_path = None if config.save_video: os.makedirs(os.path.join(output_dir, "videos"), exist_ok=True) output_video_path = os.path.join( @@ -174,36 +226,51 @@ def process_video( "margin": config.margin, "max_frame_gap": config.max_frame_gap, } + reproducibility = collect_reproducibility_info() run_config = { "logic_name": "line_detection", "dataset": dataset, "input_type": input_type, + "input_source": str(video_path), "device_name": device_name, "device_accelerator": runtime.device_accelerator, "model_path": config.model_path, "input_sha256": sha256_file(str(video_path)) if isinstance(video_path, str) else None, "model_sha256": sha256_file(config.model_path), + "line1_points": [list(config.line1.start), list(config.line1.end)], + "line2_points": [list(config.line2.start), list(config.line2.end)], + "parking_reference_point": list(config.parking_ref_point), "frame_width": width, "frame_height": height, "source_fps": float(fps), "vehicle_classes": config.vehicle_classes, + "method": config.method, "tracker_reset": True, "log_interval_sec": runtime.log_interval_sec, "yolo_conf": config.confidence_threshold, "yolo_iou": config.iou_threshold, - "yolo_device": runtime.yolo_device, + "yolo_device_requested": runtime.yolo_device, + "yolo_device": resolve_model_device(model, runtime.yolo_device), "yolo_imgsz": runtime.yolo_imgsz, "tracker_config": runtime.yolo_tracker, + "tracker_config_sha256": sha256_file(runtime.yolo_tracker), "warmup_frames": runtime.warmup_frames, "save_video": config.save_video, + "save_logs": config.save_logs, "show_display": config.show_display, "timing_schema_version": TIMING_SCHEMA_VERSION, **exp_params, + **reproducibility, } run_config["comparison_key"] = build_comparison_key(run_config) - run_config["exp_key"] = build_exp_key( - "line_detection", dataset, device_name, exp_params + condition = build_line_condition(run_config) + identity = build_run_identity( + condition, + display_name=build_display_name("line_detection", dataset, exp_params), ) + run_config["condition"] = condition + run_config.update(identity) + run_config["exp_key"] = run_config["condition_key"] # 旧データ利用箇所向けの互換alias wandb_logger = ExperimentLogger( project=runtime.wandb_project, config=run_config, @@ -371,12 +438,15 @@ def process_video( print("\n処理完了!") log_dir = Path(output_dir) / "logs" + output_paths = [] if config.save_logs: - event_logger.save_json( + json_path = event_logger.save_json( str(log_dir), tracker_summary, wandb_run_id=wandb_logger.run_id, - exp_key=run_config["exp_key"] if use_wandb else None, + execution_id=run_config["execution_id"], + condition_key=run_config["condition_key"], + exp_key=run_config["exp_key"], timing_summary={ "timing_schema_version": TIMING_SCHEMA_VERSION, "warmup_frames": runtime.warmup_frames, @@ -384,8 +454,20 @@ def process_video( **timing_stats, }, ) - event_logger.save_csv(str(log_dir)) + csv_path = event_logger.save_csv(str(log_dir)) + output_paths.extend([Path(json_path).resolve(), Path(csv_path).resolve()]) wandb_logger.save_run_id(log_dir) + if use_wandb: + output_paths.append((log_dir / "wandb_run_id.txt").resolve()) + if output_video_path is not None: + output_paths.append(Path(output_video_path).resolve()) + write_run_manifest( + Path(output_dir) / "manifests" / f"{run_config['execution_id']}.json", + config=run_config, + output_dir=Path(output_dir), + output_paths=output_paths, + wandb_run_id=wandb_logger.run_id, + ) event_logger.print_summary(tracker_summary, timing_summary=timing_stats) return timing_stats except BaseException: diff --git a/raspi/line_detection/result_output/event_logger.py b/raspi/line_detection/result_output/event_logger.py index e3a3ebee..c1424797 100644 --- a/raspi/line_detection/result_output/event_logger.py +++ b/raspi/line_detection/result_output/event_logger.py @@ -139,6 +139,8 @@ def save_json( output_dir: str, tracker_summary: Dict, wandb_run_id: str = None, + execution_id: str = None, + condition_key: str = None, exp_key: str = None, timing_summary: Dict[str, Any] = None, ) -> str: @@ -148,6 +150,10 @@ def save_json( Args: output_dir: 出力ディレクトリ tracker_summary: VehicleTrackerのサマリー + wandb_run_id: W&Bが発行したrun ID(無効時はNone) + execution_id: 実行ごとのUUID + condition_key: 条件のcanonical JSONから生成したhash + exp_key: condition_keyの後方互換alias Returns: str: 保存したファイルのパス @@ -180,6 +186,10 @@ def save_json( data["timing"] = timing_summary if wandb_run_id is not None: data["wandb_run_id"] = wandb_run_id + if execution_id is not None: + data["execution_id"] = execution_id + if condition_key is not None: + data["condition_key"] = condition_key if exp_key is not None: data["exp_key"] = exp_key diff --git a/raspi/line_detection/tests/test_event_logger.py b/raspi/line_detection/tests/test_event_logger.py index 3c53aec5..3158bd21 100644 --- a/raspi/line_detection/tests/test_event_logger.py +++ b/raspi/line_detection/tests/test_event_logger.py @@ -32,7 +32,9 @@ def test_save_json_includes_timing_and_wandb_reference(tmp_path): str(tmp_path), TRACKER_SUMMARY, wandb_run_id="run-1", - exp_key="exp-1", + execution_id="execution-1", + condition_key="condition-1", + exp_key="condition-1", timing_summary=timing, ) data = json.loads(Path(path).read_text(encoding="utf-8")) @@ -41,7 +43,9 @@ def test_save_json_includes_timing_and_wandb_reference(tmp_path): assert data["summary"]["avg_processing_time_ms"] == 20.0 assert data["timing"] == timing assert data["wandb_run_id"] == "run-1" - assert data["exp_key"] == "exp-1" + assert data["execution_id"] == "execution-1" + assert data["condition_key"] == "condition-1" + assert data["exp_key"] == "condition-1" def test_save_json_keeps_wandb_keys_absent_when_disabled(tmp_path): @@ -50,4 +54,6 @@ def test_save_json_keeps_wandb_keys_absent_when_disabled(tmp_path): data = json.loads(Path(path).read_text(encoding="utf-8")) assert "wandb_run_id" not in data + assert "execution_id" not in data + assert "condition_key" not in data assert "exp_key" not in data diff --git a/raspi/line_detection/tests/test_run_identity_integration.py b/raspi/line_detection/tests/test_run_identity_integration.py new file mode 100644 index 00000000..812ee2d2 --- /dev/null +++ b/raspi/line_detection/tests/test_run_identity_integration.py @@ -0,0 +1,66 @@ +import importlib.util +import sys +from pathlib import Path + +import pytest + + +LINE_ROOT = Path(__file__).parents[1] +RASPI_ROOT = LINE_ROOT.parent +sys.path.insert(0, str(LINE_ROOT)) +sys.path.insert(0, str(RASPI_ROOT)) + +spec = importlib.util.spec_from_file_location( + "line_detection_main_identity", LINE_ROOT / "main.py" +) +line_main = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = line_main +spec.loader.exec_module(line_main) + +from common.run_identity import build_condition_key + + +BASE_RUN_CONFIG = { + "logic_name": "line_detection", + "input_type": "file", + "input_sha256": "video-a", + "model_sha256": "model-a", + "line1_points": [[0.0, 0.0], [10.0, 0.0]], + "line2_points": [[0.0, 10.0], [10.0, 10.0]], + "parking_reference_point": [5.0, 20.0], + "vehicle_classes": [2, 7], + "method": "hybrid", + "margin": 1000.0, + "max_frame_gap": 90, + "cleanup_threshold": 150, + "yolo_conf": 0.3, + "yolo_iou": 0.3, + "yolo_device": "cpu", + "yolo_imgsz": 640, + "tracker_config": "botsort.yaml", +} + + +def test_line_condition_contains_all_geometry(): + condition = line_main.build_line_condition(BASE_RUN_CONFIG) + assert condition["line1_points"] == BASE_RUN_CONFIG["line1_points"] + assert condition["line2_points"] == BASE_RUN_CONFIG["line2_points"] + assert ( + condition["parking_reference_point"] + == BASE_RUN_CONFIG["parking_reference_point"] + ) + + +@pytest.mark.parametrize( + ("field", "different_value"), + [ + ("line1_points", [[1.0, 0.0], [10.0, 0.0]]), + ("line2_points", [[0.0, 11.0], [10.0, 10.0]]), + ("parking_reference_point", [6.0, 20.0]), + ], +) +def test_each_geometry_change_changes_integrated_condition_key(field, different_value): + base = line_main.build_line_condition(BASE_RUN_CONFIG) + changed_config = {**BASE_RUN_CONFIG, field: different_value} + changed = line_main.build_line_condition(changed_config) + assert build_condition_key(base) != build_condition_key(changed) From fbc1417bd121b6a1fbcf02a43f7f723d8cd9a212 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:04:08 +0900 Subject: [PATCH 30/57] =?UTF-8?q?fix:=20.gitignore=E3=81=8B=E3=82=89?= =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AA=E3=82=A8=E3=83=B3=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a88af6fc..ea7c60a4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,7 @@ raspi/result/prediction_visual.png .venv/ # 2ライン検知システムの出力ファイル -raspi/line_detection/data/* -data/* +data # ドキュメント docs/ From 33e7ebfca8769b063d0c689fb08fdb053f3bc2c9 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:39:25 +0900 Subject: [PATCH 31/57] =?UTF-8?q?feat:=20=E3=82=A6=E3=82=A3=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=82=A6=E4=BD=9C=E6=88=90=E6=99=82=E3=81=AB=E5=88=9D?= =?UTF-8?q?=E6=9C=9F=E3=83=95=E3=83=AC=E3=83=BC=E3=83=A0=E3=82=92=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- raspi/line_detection/line_setup/setup_lines.py | 1 + 1 file changed, 1 insertion(+) diff --git a/raspi/line_detection/line_setup/setup_lines.py b/raspi/line_detection/line_setup/setup_lines.py index b361c38a..6306f6f6 100755 --- a/raspi/line_detection/line_setup/setup_lines.py +++ b/raspi/line_detection/line_setup/setup_lines.py @@ -188,6 +188,7 @@ def run(self) -> bool: # ウィンドウを作成 cv2.namedWindow(self.window_name) + cv2.imshow(self.window_name, self.display_frame) cv2.setMouseCallback(self.window_name, self.mouse_callback) print("=" * 60) From 9257bd6f75c3d20801d593e42f850d9ba67dffb5 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:37:48 +0900 Subject: [PATCH 32/57] =?UTF-8?q?feat:=20GT=E8=AA=AD=E3=81=BF=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=81=A8count=5Ferror=E7=AE=97=E5=87=BA=E3=82=92?= =?UTF-8?q?=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/common/ground_truth.py | 148 +++++++++++++++++++++ raspi/common/tests/test_ground_truth.py | 169 ++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 raspi/common/ground_truth.py create mode 100644 raspi/common/tests/test_ground_truth.py diff --git a/raspi/common/ground_truth.py b/raspi/common/ground_truth.py new file mode 100644 index 00000000..c5e884b4 --- /dev/null +++ b/raspi/common/ground_truth.py @@ -0,0 +1,148 @@ +"""両検出方式で共有する正解台数(GT)比較の契約。""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from common.frame_timing import sha256_file + + +@dataclass(frozen=True) +class GroundTruth: + """1動画分のGT。値が無い方向は ``None``(未確認)として扱う。""" + + path: str | None + sha256: str | None + gt_in: int | None + gt_out: int | None + + @property + def is_available(self) -> bool: + return self.gt_in is not None or self.gt_out is not None + + @classmethod + def absent(cls) -> "GroundTruth": + return cls(path=None, sha256=None, gt_in=None, gt_out=None) + + +def _coerce_count(value: Any, *, key: str, source: str) -> int | None: + """GT の1方向分の値を検証する。``null`` は未確認として ``None`` を返す。""" + + if value is None: + return None + if isinstance(value, bool): + raise ValueError(f"{source}: '{key}' は真偽値を受け付けません: {value!r}") + if isinstance(value, int): + count = value + elif isinstance(value, float): + if not value.is_integer(): + raise ValueError(f"{source}: '{key}' は整数である必要があります: {value!r}") + count = int(value) + elif isinstance(value, str): + try: + count = int(value.strip()) + except ValueError as exc: + raise ValueError(f"{source}: '{key}' が数値として解釈できません: {value!r}") from exc + else: + raise ValueError(f"{source}: '{key}' の型が不正です: {value!r}") + if count < 0: + raise ValueError(f"{source}: '{key}' は0以上である必要があります: {count}") + return count + + +def _load_gt_file(path: Path, *, video_source: str | int) -> GroundTruth: + source = str(path) + try: + raw_text = path.read_text(encoding="utf-8") + except FileNotFoundError: + raise + try: + data = json.loads(raw_text) + except json.JSONDecodeError as exc: + raise ValueError(f"{source}: GT JSON の解析に失敗しました: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"{source}: GT JSON はオブジェクトである必要があります") + if "in" not in data and "out" not in data: + raise ValueError(f"{source}: GT に 'in' も 'out' もありません") + + gt_in = _coerce_count(data.get("in"), key="in", source=source) + gt_out = _coerce_count(data.get("out"), key="out", source=source) + + video_field = data.get("video") + if isinstance(video_field, str) and isinstance(video_source, str): + if Path(video_field).stem != Path(video_source).stem: + print( + f"[WARN] GTの video ('{video_field}') と入力動画 " + f"('{video_source}') のファイル名が一致しません" + ) + + return GroundTruth(path=source, sha256=sha256_file(source), gt_in=gt_in, gt_out=gt_out) + + +def resolve_gt_path(video_source: str | int, explicit_path: str | None) -> tuple[Path | None, bool]: + """GTファイルのパスを決定する。返り値は ``(path, is_explicit)``。""" + + if explicit_path: + return Path(explicit_path), True + if isinstance(video_source, str): + derived = Path(video_source).parent / f"{Path(video_source).stem}_gt.json" + return derived, False + return None, False + + +def load_ground_truth(video_source: str | int, explicit_path: str | None = None) -> GroundTruth: + """GTを読み込む。 + + 明示パス指定でファイルが無ければ ``FileNotFoundError``(操作者の意図を裏切らない)。 + 自動導出したパスが無い場合は警告のみで ``GroundTruth.absent()`` を返す。 + """ + + path, is_explicit = resolve_gt_path(video_source, explicit_path) + if path is None: + return GroundTruth.absent() + if not path.exists(): + if is_explicit: + raise FileNotFoundError(f"GT ファイルが見つかりません: {path}") + print(f"[WARN] GT ファイルが見つかりません: {path}") + return GroundTruth.absent() + return _load_gt_file(path, video_source=video_source) + + +def compute_count_error(count_in: int, count_out: int, gt: GroundTruth) -> dict[str, Any]: + """評価可能な方向だけを対象に台数誤差を算出する。""" + + count_error_in = abs(count_in - gt.gt_in) if gt.gt_in is not None else None + count_error_out = abs(count_out - gt.gt_out) if gt.gt_out is not None else None + + parts = [value for value in (count_error_in, count_error_out) if value is not None] + count_error = sum(parts) if parts else None + + return { + "count_error": count_error, + "count_error_in": count_error_in, + "count_error_out": count_error_out, + } + + +def build_ground_truth_config(gt: GroundTruth) -> dict[str, Any]: + """run configへ埋め込むGT識別情報。""" + + return { + "ground_truth_config": gt.path, + "ground_truth_sha256": gt.sha256, + "gt_in": gt.gt_in, + "gt_out": gt.gt_out, + } + + +def build_ground_truth_summary(count_in: int, count_out: int, gt: GroundTruth) -> dict[str, Any]: + """W&B summaryへ埋め込むGT比較結果。""" + + return { + "gt_in": gt.gt_in, + "gt_out": gt.gt_out, + **compute_count_error(count_in, count_out, gt), + } diff --git a/raspi/common/tests/test_ground_truth.py b/raspi/common/tests/test_ground_truth.py new file mode 100644 index 00000000..a32fda18 --- /dev/null +++ b/raspi/common/tests/test_ground_truth.py @@ -0,0 +1,169 @@ +import json + +import pytest + +from common.frame_timing import COMPARISON_CONFIG_KEYS +from common.ground_truth import ( + GroundTruth, + build_ground_truth_config, + build_ground_truth_summary, + compute_count_error, + load_ground_truth, +) + + +def write_gt(tmp_path, name, data): + path = tmp_path / name + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +def test_loads_in_and_out_and_ignores_roi_key(tmp_path): + video = tmp_path / "clip.MOV" + write_gt(tmp_path, "clip_gt.json", {"video": "clip.MOV", "roi": [[1, 2]], "in": 22, "out": 0}) + gt = load_ground_truth(str(video)) + assert gt.gt_in == 22 + assert gt.gt_out == 0 + assert gt.sha256 is not None + + +def test_auto_derives_path_from_video_stem(tmp_path): + video = tmp_path / "clip.MOV" + write_gt(tmp_path, "clip_gt.json", {"in": 1, "out": 2}) + gt = load_ground_truth(str(video)) + assert gt.gt_in == 1 + assert gt.gt_out == 2 + + +def test_missing_auto_derived_file_returns_absent_and_warns(tmp_path, capsys): + video = tmp_path / "clip.MOV" + gt = load_ground_truth(str(video)) + assert gt == GroundTruth.absent() + assert "[WARN]" in capsys.readouterr().out + + +def test_missing_explicit_path_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(tmp_path / "missing_gt.json")) + + +def test_malformed_json_raises(tmp_path): + path = tmp_path / "bad_gt.json" + path.write_text("{not json", encoding="utf-8") + with pytest.raises(ValueError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_non_object_json_raises(tmp_path): + path = write_gt(tmp_path, "bad_gt.json", [1, 2, 3]) + with pytest.raises(ValueError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_null_direction_is_unverified(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": 22, "out": None}) + gt = load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + assert gt.gt_in == 22 + assert gt.gt_out is None + + +def test_missing_out_key_is_unverified(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": 22}) + gt = load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + assert gt.gt_out is None + + +def test_without_in_and_out_raises(tmp_path): + path = write_gt(tmp_path, "gt.json", {"roi": []}) + with pytest.raises(ValueError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_negative_count_is_rejected(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": -1, "out": 0}) + with pytest.raises(ValueError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_boolean_count_is_rejected(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": True, "out": 0}) + with pytest.raises(ValueError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_non_numeric_string_is_rejected(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": "abc", "out": 0}) + with pytest.raises(ValueError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_numeric_string_is_accepted(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": "22", "out": "0"}) + gt = load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + assert gt.gt_in == 22 + assert gt.gt_out == 0 + + +def test_non_integral_float_is_rejected(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": 22.5, "out": 0}) + with pytest.raises(ValueError): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_camera_source_has_no_ground_truth(): + gt = load_ground_truth(0) + assert gt == GroundTruth.absent() + + +def test_video_stem_mismatch_warns_but_loads(tmp_path, capsys): + path = write_gt(tmp_path, "gt.json", {"video": "other.MOV", "in": 1, "out": 0}) + gt = load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + assert gt.gt_in == 1 + assert "[WARN]" in capsys.readouterr().out + + +def test_count_error_sums_both_directions(): + gt = GroundTruth(path=None, sha256=None, gt_in=22, gt_out=0) + result = compute_count_error(20, 1, gt) + assert result == {"count_error": 3, "count_error_in": 2, "count_error_out": 1} + + +def test_count_error_excludes_null_direction(): + gt = GroundTruth(path=None, sha256=None, gt_in=22, gt_out=None) + result = compute_count_error(20, 5, gt) + assert result["count_error"] == result["count_error_in"] == 2 + assert result["count_error_out"] is None + + +def test_count_error_is_none_without_ground_truth(): + result = compute_count_error(20, 5, GroundTruth.absent()) + assert result == {"count_error": None, "count_error_in": None, "count_error_out": None} + + +def test_count_error_matches_roi_04_formula_when_both_directions_present(): + gt = GroundTruth(path=None, sha256=None, gt_in=22, gt_out=0) + count_in, count_out = 19, 2 + result = compute_count_error(count_in, count_out, gt) + assert result["count_error"] == abs(count_in - gt.gt_in) + abs(count_out - gt.gt_out) + + +def test_comparison_key_ignores_ground_truth_keys(): + for key in ("ground_truth_config", "ground_truth_sha256", "gt_in", "gt_out"): + assert key not in COMPARISON_CONFIG_KEYS + + +def test_build_ground_truth_config_keys(): + gt = GroundTruth(path="/tmp/gt.json", sha256="abc", gt_in=22, gt_out=0) + assert build_ground_truth_config(gt) == { + "ground_truth_config": "/tmp/gt.json", + "ground_truth_sha256": "abc", + "gt_in": 22, + "gt_out": 0, + } + + +def test_build_ground_truth_summary_includes_count_error(): + gt = GroundTruth(path=None, sha256=None, gt_in=22, gt_out=0) + summary = build_ground_truth_summary(20, 1, gt) + assert summary["gt_in"] == 22 + assert summary["count_error"] == 3 From c8133ac8daec34f7ef4a65b1178a4e4307bd5567 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:41:18 +0900 Subject: [PATCH 33/57] =?UTF-8?q?feat:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E3=81=B8GT=E6=AF=94=E8=BC=83=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/main.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index 232196e6..29be11c5 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -48,6 +48,12 @@ collect_reproducibility_info, write_run_manifest, ) +from common.ground_truth import ( + GroundTruth, + build_ground_truth_config, + build_ground_truth_summary, + load_ground_truth, +) WEBCAM_FPS = 30.0 @@ -135,6 +141,7 @@ def process_video( use_wandb: bool = False, device_name: str = platform.node(), runtime: RuntimeSettings | None = None, + ground_truth: GroundTruth | None = None, ): """ 動画を処理 @@ -143,8 +150,10 @@ def process_video( video_path: 入力動画のパス config: 設定 output_dir: 出力ディレクトリ + ground_truth: 正解台数(GT)。省略時は比較なし """ runtime = runtime or RuntimeSettings.from_env() + gt = ground_truth or GroundTruth.absent() print("\n" + "=" * 60) print("2ライン検知システム") @@ -261,6 +270,7 @@ def process_video( "timing_schema_version": TIMING_SCHEMA_VERSION, **exp_params, **reproducibility, + **build_ground_truth_config(gt), } run_config["comparison_key"] = build_comparison_key(run_config) condition = build_line_condition(run_config) @@ -428,13 +438,23 @@ def process_video( measured = require_measured_timings(timing_records) timing_stats = compute_timing_stats(measured, float(fps)) tracker_summary = tracker.get_summary() + accuracy_summary = build_ground_truth_summary( + tracker_summary["total_in"], tracker_summary["total_out"], gt + ) wandb_logger.set_summaries({ "count_in": tracker_summary["total_in"], "count_out": tracker_summary["total_out"], "total_frames": frame_id, "measured_frames": len(measured), + **accuracy_summary, **timing_stats, }) + if gt.is_available: + print( + f"GT比較: count_error={accuracy_summary['count_error']} " + f"(in={accuracy_summary['count_error_in']}, " + f"out={accuracy_summary['count_error_out']})" + ) print("\n処理完了!") log_dir = Path(output_dir) / "logs" @@ -521,6 +541,11 @@ def main(): default=None, help="比較対象デバイス名" ) + parser.add_argument( + "--gt", + default=None, + help="正解台数JSONのパス(省略時は<動画名>_gt.jsonを自動探索)" + ) args = parser.parse_args() @@ -563,6 +588,9 @@ def main(): if args.display: config.show_display = True + # GTを読み込み(--gt省略時は動画名から自動探索。無ければ比較なしで続行) + ground_truth = load_ground_truth(video_path, args.gt) + except Exception as e: print(f"設定エラー: {e}") return 1 @@ -577,6 +605,7 @@ def main(): use_wandb=args.wandb or runtime.use_wandb, device_name=args.device_name or os.getenv("EXP_DEVICE_NAME", platform.node()), runtime=runtime, + ground_truth=ground_truth, ) return 0 except KeyboardInterrupt: From d97e3c75058d22ff598a4f79824b17d4b6780e8e Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:42:49 +0900 Subject: [PATCH 34/57] =?UTF-8?q?feat:=20GT=E8=AD=98=E5=88=A5=E6=83=85?= =?UTF-8?q?=E5=A0=B1=E3=82=92condition=5Fkey=E3=81=B8=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/main.py | 3 + .../tests/test_ground_truth_integration.py | 75 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 raspi/line_detection/tests/test_ground_truth_integration.py diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index 29be11c5..fc76a4f1 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -96,6 +96,9 @@ def from_env(cls) -> "RuntimeSettings": "line1_points", "line2_points", "parking_reference_point", + "ground_truth_sha256", + "gt_in", + "gt_out", "vehicle_classes", "method", "margin", diff --git a/raspi/line_detection/tests/test_ground_truth_integration.py b/raspi/line_detection/tests/test_ground_truth_integration.py new file mode 100644 index 00000000..e0732fec --- /dev/null +++ b/raspi/line_detection/tests/test_ground_truth_integration.py @@ -0,0 +1,75 @@ +import importlib.util +import sys +from pathlib import Path + +import pytest + + +LINE_ROOT = Path(__file__).parents[1] +RASPI_ROOT = LINE_ROOT.parent +sys.path.insert(0, str(LINE_ROOT)) +sys.path.insert(0, str(RASPI_ROOT)) + +spec = importlib.util.spec_from_file_location( + "line_detection_main_ground_truth", LINE_ROOT / "main.py" +) +line_main = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = line_main +spec.loader.exec_module(line_main) + +from common.run_identity import build_condition_key + + +BASE_RUN_CONFIG = { + "logic_name": "line_detection", + "input_type": "file", + "input_sha256": "video-a", + "model_sha256": "model-a", + "line1_points": [[0.0, 0.0], [10.0, 0.0]], + "line2_points": [[0.0, 10.0], [10.0, 10.0]], + "parking_reference_point": [5.0, 20.0], + "ground_truth_config": "/tmp/gt.json", + "ground_truth_sha256": "gt-hash-a", + "gt_in": 22, + "gt_out": 0, + "vehicle_classes": [2, 7], + "method": "hybrid", + "margin": 1000.0, + "max_frame_gap": 90, + "cleanup_threshold": 150, + "yolo_conf": 0.3, + "yolo_iou": 0.3, + "yolo_device": "cpu", + "yolo_imgsz": 640, + "tracker_config": "botsort.yaml", +} + + +def test_line_condition_includes_ground_truth_identity_keys(): + condition = line_main.build_line_condition(BASE_RUN_CONFIG) + assert condition["ground_truth_sha256"] == BASE_RUN_CONFIG["ground_truth_sha256"] + assert condition["gt_in"] == BASE_RUN_CONFIG["gt_in"] + assert condition["gt_out"] == BASE_RUN_CONFIG["gt_out"] + assert "ground_truth_config" not in condition + + +@pytest.mark.parametrize( + ("field", "different_value"), + [ + ("ground_truth_sha256", "gt-hash-b"), + ("gt_in", 23), + ("gt_out", 1), + ], +) +def test_each_ground_truth_change_changes_condition_key(field, different_value): + base = line_main.build_line_condition(BASE_RUN_CONFIG) + changed_config = {**BASE_RUN_CONFIG, field: different_value} + changed = line_main.build_line_condition(changed_config) + assert build_condition_key(base) != build_condition_key(changed) + + +def test_ground_truth_path_does_not_change_condition_key(): + base = line_main.build_line_condition(BASE_RUN_CONFIG) + changed_config = {**BASE_RUN_CONFIG, "ground_truth_config": "/tmp/other_gt.json"} + changed = line_main.build_line_condition(changed_config) + assert build_condition_key(base) == build_condition_key(changed) From 324ae524d893f4e09eeb501c4d4173d4a7d5df27 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:44:52 +0900 Subject: [PATCH 35/57] =?UTF-8?q?feat:=20GT=E6=AF=94=E8=BC=83=E7=B5=90?= =?UTF-8?q?=E6=9E=9C=E3=82=92=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88=E3=83=AD?= =?UTF-8?q?=E3=82=B0JSON=E3=81=B8=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/main.py | 1 + .../result_output/event_logger.py | 4 ++++ .../line_detection/tests/test_event_logger.py | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index fc76a4f1..a8989885 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -476,6 +476,7 @@ def process_video( "measured_frames": len(measured), **timing_stats, }, + accuracy_summary=accuracy_summary if gt.is_available else None, ) csv_path = event_logger.save_csv(str(log_dir)) output_paths.extend([Path(json_path).resolve(), Path(csv_path).resolve()]) diff --git a/raspi/line_detection/result_output/event_logger.py b/raspi/line_detection/result_output/event_logger.py index c1424797..bdb20986 100644 --- a/raspi/line_detection/result_output/event_logger.py +++ b/raspi/line_detection/result_output/event_logger.py @@ -143,6 +143,7 @@ def save_json( condition_key: str = None, exp_key: str = None, timing_summary: Dict[str, Any] = None, + accuracy_summary: Dict[str, Any] = None, ) -> str: """ JSONファイルに保存 @@ -154,6 +155,7 @@ def save_json( execution_id: 実行ごとのUUID condition_key: 条件のcanonical JSONから生成したhash exp_key: condition_keyの後方互換alias + accuracy_summary: GT比較結果(gt_in/gt_out/count_error等、GT無しはNone) Returns: str: 保存したファイルのパス @@ -192,6 +194,8 @@ def save_json( data["condition_key"] = condition_key if exp_key is not None: data["exp_key"] = exp_key + if accuracy_summary is not None: + data["accuracy"] = accuracy_summary # JSONファイルに書き込み with open(filepath, 'w', encoding='utf-8') as f: diff --git a/raspi/line_detection/tests/test_event_logger.py b/raspi/line_detection/tests/test_event_logger.py index 3158bd21..bc2deee6 100644 --- a/raspi/line_detection/tests/test_event_logger.py +++ b/raspi/line_detection/tests/test_event_logger.py @@ -57,3 +57,25 @@ def test_save_json_keeps_wandb_keys_absent_when_disabled(tmp_path): assert "execution_id" not in data assert "condition_key" not in data assert "exp_key" not in data + + +def test_save_json_includes_accuracy_block_when_provided(tmp_path): + logger = EventLogger(video_path="test.mp4") + accuracy = {"gt_in": 22, "gt_out": 0, "count_error": 2, "count_error_in": 2, "count_error_out": 0} + + path = logger.save_json( + str(tmp_path), + TRACKER_SUMMARY, + accuracy_summary=accuracy, + ) + data = json.loads(Path(path).read_text(encoding="utf-8")) + + assert data["accuracy"] == accuracy + + +def test_save_json_omits_accuracy_block_when_absent(tmp_path): + logger = EventLogger(video_path="test.mp4") + path = logger.save_json(str(tmp_path), TRACKER_SUMMARY) + data = json.loads(Path(path).read_text(encoding="utf-8")) + + assert "accuracy" not in data From e5dd37f263498401481fa07d55d380b69936573b Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:46:30 +0900 Subject: [PATCH 36/57] =?UTF-8?q?docs:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E3=81=AEGT=E6=AF=94=E8=BC=83=E6=89=8B?= =?UTF-8?q?=E9=A0=86=E3=82=92=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/README.md | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md index c53bef1a..ecd8e2ef 100644 --- a/raspi/line_detection/README.md +++ b/raspi/line_detection/README.md @@ -157,6 +157,48 @@ WANDB_MODE=offline python main.py --input data/inputs/test.mp4 \ wandb sync ``` +### 台数精度の比較(GT) + +正解台数(GT)のJSONを`--gt`で指定すると、検出結果との差を`count_error`として +記録する。ROI方式と同じGTファイルを共有し、`roi`キー等ROI方式固有の項目は +無視する。 + +```bash +python main.py --input data/inputs/test.mp4 \ + --gt ../roi-counter/data/inputs/configs/IMG_2787_gt.json +``` + +`--gt`を省略した場合は`<動画名>_gt.json`を入力動画と同じディレクトリから +自動探索する。見つからなければ警告のみでGT比較なしのまま続行する。 +`--gt`で明示的に指定したパスが存在しない場合は起動時にエラーで停止する。 + +GTのJSON形式: + +```json +{ + "in": 22, + "out": 0 +} +``` + +- 値が数値(`0`を含む)なら「確認済み」として評価する +- 値が`null`または省略時は「未確認」として**その方向は評価対象から除外**する + (`0`と`null`は明確に区別する) + +記録されるキー: + +- `gt_in` / `gt_out`: GTの値(未確認は`None`) +- `count_error_in` / `count_error_out`: 方向ごとの絶対誤差(未評価の方向は`None`) +- `count_error`: 評価した方向の誤差合計。**評価方向数によってスケールが変わるため、 + ROI方式の`04_multi_video_mae.py`と直接比較してよいのはIN/OUT両方が評価済みのときだけ**。 + 片方のみの評価では方向別キー(`count_error_in`等)を使うこと + +GT情報(`ground_truth_sha256`・`gt_in`・`gt_out`)は`condition_key`に含まれるため、 +GTの有無・内容が変わると同一条件とはみなされなくなる。 + +> **注意**: ROI方式の`04_multi_video_mae.py`はGTの`out`が`null`だとエラーになる。 +> `null`を含むGTをROI方式の`GT_DIR`に置かないこと。 + ## 出力ファイル W&Bの有効・無効にかかわらず、runごとに @@ -198,10 +240,20 @@ W&Bの有効・無効にかかわらず、runごとに "current_parked": 2, "high_confidence_events": 6, "normal_confidence_events": 2 + }, + "accuracy": { + "gt_in": 22, + "gt_out": 0, + "count_error": 3, + "count_error_in": 2, + "count_error_out": 1 } } ``` +`accuracy`ブロックは`--gt`でGTを指定した場合のみ追加される。GTを指定しない +実行では従来どおりこのキー自体が存在しない。 + ### イベントログ CSV (`data/outputs/logs/events_YYYYMMDD_HHMMSS.csv`) Excelで開きやすいCSV形式: From 8642577d3ff26290a271fdf3f34c82b46cd921ac Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:20:46 +0900 Subject: [PATCH 37/57] =?UTF-8?q?chore:=20.gitignore=E3=81=AB=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=AB=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF?= =?UTF-8?q?=E3=83=88=E3=83=AA=E7=94=A8=E3=81=AE=E3=83=AB=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs.local/等の *.local ディレクトリを常時ignore対象にする。 ブランチ切替時の checkout 失敗を防ぐため先にコミットしておく。 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ea7c60a4..8191bc1b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ docs/ docs/* !raspi/line_detection/docs/ !raspi/line_detection/docs/wandb_integration_spec_v2.md + +# ローカルディレクトリ +**/*.local \ No newline at end of file From 795146341dec641fadbadf7c106a98b1fb6a874a Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:38:31 +0900 Subject: [PATCH 38/57] =?UTF-8?q?chore:=20pytest=20=E3=82=92=20dev=20depen?= =?UTF-8?q?dency=20=E3=81=A8=E3=81=97=E3=81=A6=E6=98=8E=E7=A4=BA=E5=AE=A3?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これまでROIブランチ側でのuv sync時にインストールされたpytestが .venvに残留していただけで、pyproject.tomlには宣言されていなかった。 このため単独でuv syncするとpytestごと削除される状態だった。 Co-Authored-By: Claude Sonnet 5 --- pyproject.toml | 5 +++++ uv.lock | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1756d41f..6891a727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,3 +21,8 @@ dependencies = [ "lap>=0.5.13", "wandb>=0.28.0", ] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] diff --git a/uv.lock b/uv.lock index 798eb286..03a5661a 100644 --- a/uv.lock +++ b/uv.lock @@ -359,6 +359,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1021,6 +1030,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "polars" version = "1.41.2" @@ -1182,6 +1200,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1191,6 +1218,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1480,6 +1523,11 @@ dependencies = [ { name = "wandb" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "lap", specifier = ">=0.5.13" }, @@ -1493,6 +1541,9 @@ requires-dist = [ { name = "wandb", specifier = ">=0.28.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "triton" version = "3.7.1" From 3d2eb725c801dc1a4669d4066a9a9f4fc8529ca7 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:39:22 +0900 Subject: [PATCH 39/57] =?UTF-8?q?build:=20ultralytics=20=E3=82=92=208.4.72?= =?UTF-8?q?=20=E3=81=AB=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROIブランチのtracker初期化処理がUltralyticsの内部APIに依存しており 8.4.72に固定されているため(raspi/roi-counter/README.md参照)、 Phase 3以降の両方式比較で同一バージョンの上で計測できるよう 2ラインブランチ側も同じバージョンへ揃える(D10)。 uv run pytest raspi/ -q: 77 passed (変更前と同数) 実モデルでのスモークチェック(model.track()の実行)も正常終了を確認。 Co-Authored-By: Claude Sonnet 5 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6891a727..d4018b65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ # YOLOv8とトラッキング - "ultralytics>=8.2.52", + "ultralytics==8.4.72", # 動画処理 "opencv-python>=4.8.1", # 設定管理 diff --git a/uv.lock b/uv.lock index 03a5661a..e3afa85b 100644 --- a/uv.lock +++ b/uv.lock @@ -1537,7 +1537,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "torch", specifier = ">=2.2.2" }, { name = "torchvision", specifier = ">=0.17.2" }, - { name = "ultralytics", specifier = ">=8.2.52" }, + { name = "ultralytics", specifier = "==8.4.72" }, { name = "wandb", specifier = ">=0.28.0" }, ] @@ -1591,7 +1591,7 @@ wheels = [ [[package]] name = "ultralytics" -version = "8.4.75" +version = "8.4.72" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, @@ -1608,9 +1608,9 @@ dependencies = [ { name = "torchvision" }, { name = "ultralytics-thop" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/0a/a2b95ded35da98f1671adbb8e1a328deca191401e9efab3e6d06bf39b9b0/ultralytics-8.4.75.tar.gz", hash = "sha256:a252d424d469c08b06822861fb5905e72d9e489d538a0cd6531e74b3ed99fedb", size = 1108631, upload-time = "2026-06-21T19:28:32.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/0798bb946daeca484db9f1c431b1013ed54bc92bb21778c9239a15ef992e/ultralytics-8.4.72.tar.gz", hash = "sha256:10befe077c8bb5b265c958a5181a905de5e592208e8ec6358f151c17d0b909e1", size = 1107766, upload-time = "2026-06-19T22:47:05.276Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/8e/fae8d55844fad73eca40fdaa5cea9ae9abf843e40864025275283af55f8c/ultralytics-8.4.75-py3-none-any.whl", hash = "sha256:30fab7c1ac5b16a4604bc77e5ab05ca38417b071639871b5ef1d75cc5e0a2e1f", size = 1317743, upload-time = "2026-06-21T19:28:28.565Z" }, + { url = "https://files.pythonhosted.org/packages/96/84/29be8f2371458d2557ed67045976da45884a8a40cce092d87995d04a2933/ultralytics-8.4.72-py3-none-any.whl", hash = "sha256:b38e73a339ea256a69f41f14b4418cdcbdd6e45449ff9d2adac386406cf6cfbb", size = 1316969, upload-time = "2026-06-19T22:47:01.017Z" }, ] [[package]] From ffa889ebc758ec3532355c14b261d3926894c5b4 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:19:40 +0900 Subject: [PATCH 40/57] =?UTF-8?q?fix:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E4=BA=A4=E5=B7=AE=E5=88=A4=E5=AE=9A=E3=82=92=E5=8D=98=E4=BD=8D?= =?UTF-8?q?=E6=98=AF=E6=AD=A3=E3=83=BB=E6=9C=89=E9=99=90=E7=B7=9A=E5=88=86?= =?UTF-8?q?=E3=83=BB=E3=83=92=E3=82=B9=E3=83=86=E3=83=AA=E3=82=B7=E3=82=B9?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E3=81=B8=E4=BD=9C=E3=82=8A=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #98対応。以下3つの複合バグを1つの修正としてまとめて解消する (単位だけ直すと低速通過の取りこぼしが顕在化し現状より悪化するため分割不可): 1. 単位バグ: side_of_line()は外積値(距離×ライン長)を返すが、MARGINと 直接比較していた。実効的な判定保留帯の幅はLine1で0.0086px、Line2で 0.037pxとほぼゼロだった。signed_distance()を追加しpx単位で比較する。 2. 無限直線バグ: ラインの延長線を跨いだだけでも交差と判定されていた。 segment_crossing_param()で線分への射影係数tを求め、endpoint_margin_px を加味した範囲内かどうかで有限線分判定を行う。 3. 履歴なしバグ: 前後2フレームしか見ないため、判定保留帯を跨ぐのに 複数フレームかかる低速通過が一度も検出されなかった。LineTransitionState による安定側の記憶(ヒステリシス)を導入し、反対の安定側へ抜けた時点で 交差を確定する。 側の反転が起きたが有限線分判定が不成立の場合も、stable_side/ last_stable_pointは判定の成否と無関係に更新する(コード内コメント参照)。 更新を止めると、以降のフレームが古い参照点を基準に判定を続けてしまい、 間に本物の交差があっても検出できなくなるため。 信頼度(#97)には触れていない。カウント確定のタイミングはLine1通過のまま。 MARGIN_PX/ENDPOINT_MARGIN_PXへ移行。旧MARGINが残っている場合は起動時 エラーとする(単位が異なるため自動変換しない)。.envの新既定値は0.0 (3cのスイープで正式決定する暫定値)。 新規テスト30件(test_line_crossing.py 22件、test_tracker.py 8件)。 uv run pytest raspi/ -q: 107 passed(既存77+新規30)。 実動画(IMG_2787.MOV, 300フレーム)でのエンドツーエンド動作も確認済み。 Co-Authored-By: Claude Sonnet 5 --- raspi/line_detection/.env.template | 3 +- raspi/line_detection/README.md | 5 +- raspi/line_detection/detection/config.py | 24 +- .../line_detection/detection/line_crossing.py | 213 ++++++++++++++---- raspi/line_detection/detection/tracker.py | 6 + .../line_detection/line_setup/setup_lines.py | 3 +- raspi/line_detection/main.py | 24 +- .../tests/test_ground_truth_integration.py | 4 +- .../tests/test_line_crossing.py | 211 +++++++++++++++++ .../tests/test_run_identity_integration.py | 4 +- raspi/line_detection/tests/test_tracker.py | 171 ++++++++++++++ .../visualize_lines_and_vehicles.py | 20 +- 12 files changed, 618 insertions(+), 70 deletions(-) create mode 100644 raspi/line_detection/tests/test_line_crossing.py create mode 100644 raspi/line_detection/tests/test_tracker.py diff --git a/raspi/line_detection/.env.template b/raspi/line_detection/.env.template index e6ee1bcc..d2a47f31 100644 --- a/raspi/line_detection/.env.template +++ b/raspi/line_detection/.env.template @@ -32,7 +32,8 @@ PARKING_REF_X=550 PARKING_REF_Y=100 # 検知パラメータ -MARGIN=1000.0 +MARGIN_PX=0.0 # 判定保留帯の半幅(px)。3cで正式決定(暫定値) +ENDPOINT_MARGIN_PX=0.0 # 有限線分判定の端点許容量(px)。Phase 3の既定(変更しない) MAX_FRAME_GAP=90 # 3秒@30fps CLEANUP_THRESHOLD=150 # 5秒@30fps diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md index ecd8e2ef..3fc9db0c 100644 --- a/raspi/line_detection/README.md +++ b/raspi/line_detection/README.md @@ -282,7 +282,8 @@ CONFIDENCE_THRESHOLD=0.3 # 検知信頼度閾値(0.0-1.0) ### 検知パラメータ ```bash -MARGIN=1000.0 # ライン交差判定のマージン +MARGIN_PX=0.0 # 判定保留帯の半幅(px)。3cで正式決定 +ENDPOINT_MARGIN_PX=0.0 # 有限線分判定の端点許容量(px) MAX_FRAME_GAP=90 # Line1とLine2の最大フレーム差(3秒@30fps) CLEANUP_THRESHOLD=150 # 古い追跡をクリーンアップ(5秒@30fps) ``` @@ -351,7 +352,7 @@ IF Line2のみ交差(Line1交差なし): **考えられる原因:** 1. ライン位置が適切でない → `setup_lines.py`で再設定 -2. `MARGIN`が大きすぎる/小さすぎる → `.env`で調整 +2. `MARGIN_PX`が大きすぎる/小さすぎる → `.env`で調整(px単位) 3. `MAX_FRAME_GAP`が適切でない → 車両の通過速度に合わせて調整 ## 参考 diff --git a/raspi/line_detection/detection/config.py b/raspi/line_detection/detection/config.py index 53f5c92e..aa624c95 100644 --- a/raspi/line_detection/detection/config.py +++ b/raspi/line_detection/detection/config.py @@ -39,7 +39,8 @@ class Config: parking_ref_point: Tuple[float, float] # 駐車場基準点 # 検知パラメータ - margin: float # ライン交差判定のマージン + margin_px: float # 判定保留帯の半幅(px) + endpoint_margin_px: float # 有限線分判定における端点の許容量(px) max_frame_gap: int # Line1とLine2の最大フレーム差 cleanup_threshold: int # 古い追跡をクリーンアップするフレーム数 iou_threshold: float # トラッキングのIOU閾値 @@ -116,7 +117,16 @@ def from_env(cls, env_path: str = None): ) # 検知パラメータ - margin = float(os.getenv("MARGIN", "1000.0")) + if os.getenv("MARGIN") is not None and os.getenv("MARGIN_PX") is None: + raise ValueError( + "MARGIN は廃止されました。MARGIN_PX(単位: px)へ移行してください。\n" + " 旧: MARGIN=10.0 は外積値(距離×ライン長)との比較でした。\n" + " 新: MARGIN_PX は signed_distance との比較で、単位が px そのものです。\n" + " 数値をそのまま引き継ぐことはできません(ラインごとに換算係数が異なるため)。\n" + " .env の MARGIN 行を削除し、MARGIN_PX と ENDPOINT_MARGIN_PX を追加してください。" + ) + margin_px = float(os.getenv("MARGIN_PX", "0.0")) + endpoint_margin_px = float(os.getenv("ENDPOINT_MARGIN_PX", "0.0")) max_frame_gap = int(os.getenv("MAX_FRAME_GAP", "90")) cleanup_threshold = int(os.getenv("CLEANUP_THRESHOLD", "150")) iou_threshold = float(os.getenv("IOU_THRESHOLD", "0.3")) @@ -137,7 +147,8 @@ def from_env(cls, env_path: str = None): line1=line1, line2=line2, parking_ref_point=parking_ref_point, - margin=margin, + margin_px=margin_px, + endpoint_margin_px=endpoint_margin_px, max_frame_gap=max_frame_gap, cleanup_threshold=cleanup_threshold, iou_threshold=iou_threshold, @@ -166,8 +177,11 @@ def validate(self): if self.confidence_threshold < 0 or self.confidence_threshold > 1: errors.append(f"confidence_thresholdは0-1の範囲で設定してください: {self.confidence_threshold}") - if self.margin < 0: - errors.append(f"marginは正の値である必要があります: {self.margin}") + if self.margin_px < 0: + errors.append(f"margin_pxは0以上の値である必要があります: {self.margin_px}") + + if self.endpoint_margin_px < 0: + errors.append(f"endpoint_margin_pxは0以上の値である必要があります: {self.endpoint_margin_px}") if self.max_frame_gap < 0: errors.append(f"max_frame_gapは正の値である必要があります: {self.max_frame_gap}") diff --git a/raspi/line_detection/detection/line_crossing.py b/raspi/line_detection/detection/line_crossing.py index cdb90eda..9110c46a 100644 --- a/raspi/line_detection/detection/line_crossing.py +++ b/raspi/line_detection/detection/line_crossing.py @@ -1,8 +1,10 @@ """ ライン交差検知モジュール -外積法を使用してライン交差を判定する +外積法とヒステリシス方式を使用してライン交差を判定する """ +import math +from dataclasses import dataclass from typing import Tuple, Optional, List from detection.config import Line @@ -23,6 +25,10 @@ def side_of_line(point: Tuple[float, float], > 0: ポイントはライン の片側 < 0: ポイントはラインの反対側 = 0: ポイントはライン上 + + Note: + 外積値は「距離 × ライン長」に等しく、単位はpxではない。 + px単位の距離が必要な場合は signed_distance() を使うこと。 """ # 外積: (B-A) × (P-A) # = (x2-x1) * (py-y1) - (y2-y1) * (px-x1) @@ -30,6 +36,105 @@ def side_of_line(point: Tuple[float, float], (line_end[1] - line_start[1]) * (point[0] - line_start[0]) +def line_length(line: Line) -> float: + """ + ラインの長さ(px)を計算 + + Args: + line: 対象のライン + + Returns: + float: ラインの長さ(px) + """ + return math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1]) + + +def signed_distance(point: Tuple[float, float], line: Line) -> float: + """ + ラインからの符号付き距離(px)を計算 + + Args: + point: 判定するポイント (x, y) + line: 対象のライン + + Returns: + float: 符号付き距離(px)。符号の向きは side_of_line() と同じ。 + """ + return side_of_line(point, line.start, line.end) / line_length(line) + + +def classify_side(point: Tuple[float, float], line: Line, margin_px: float) -> int: + """ + ポイントがラインのどちら側の安定領域にあるか分類する + + Args: + point: 判定するポイント (x, y) + line: 対象のライン + margin_px: 判定保留帯の半幅(px) + + Returns: + int: + +1: signed_distance が +margin_px を超える(安定側) + -1: signed_distance が -margin_px を下回る(安定側) + 0: 判定保留帯の中(どちらの側かを判断しない) + """ + d = signed_distance(point, line) + if d > margin_px: + return 1 + if d < -margin_px: + return -1 + return 0 + + +def segment_crossing_param(p_from: Tuple[float, float], + p_to: Tuple[float, float], + line: Line) -> Optional[float]: + """ + p_from→p_to の移動線分が line の(無限直線としての)交点を、 + line 自身の長さを1とした射影係数 t として返す + + Args: + p_from: 移動前のポイント + p_to: 移動後のポイント + line: 対象のライン + + Returns: + Optional[float]: + None: p_from と p_to が line に対して同じ側(交差なし) + float: 射影係数 t。t∈[0,1] とは限らない + (呼び出し側が endpoint_margin_px を加味した許容範囲で + 成立を判定すること) + """ + d_from = signed_distance(p_from, line) + d_to = signed_distance(p_to, line) + + if d_from * d_to >= 0: + return None + + denom = d_from - d_to + if denom == 0.0: + return None # 防御的ガード(理論上ここには来ない: 符号が逆なら値は必ず異なる) + + u = d_from / denom + cross_x = p_from[0] + u * (p_to[0] - p_from[0]) + cross_y = p_from[1] + u * (p_to[1] - p_from[1]) + + ab_x = line.end[0] - line.start[0] + ab_y = line.end[1] - line.start[1] + ap_x = cross_x - line.start[0] + ap_y = cross_y - line.start[1] + length_sq = ab_x * ab_x + ab_y * ab_y + + return (ap_x * ab_x + ap_y * ab_y) / length_sq + + +@dataclass +class LineTransitionState: + """track 1本 × ライン1本ぶんのヒステリシス状態""" + stable_side: Optional[int] = None + last_stable_point: Optional[Tuple[float, float]] = None + + def get_vehicle_point(bbox: List[float]) -> Tuple[float, float]: """ bboxから車両代表点(底面中央)を取得 @@ -46,24 +151,29 @@ def get_vehicle_point(bbox: List[float]) -> Tuple[float, float]: class LineCrossingDetector: - """ライン交差検知クラス""" + """ライン交差検知クラス(ヒステリシス方式)""" def __init__(self, line1: Line, line2: Line, parking_ref_point: Tuple[float, float], - margin: float = 1000.0): + margin_px: float = 1000.0, + endpoint_margin_px: float = 0.0): """ Args: line1: Line1(入口側ライン) line2: Line2(駐車場側ライン) parking_ref_point: 駐車場基準点 - margin: ライン交差判定のマージン + margin_px: 判定保留帯の半幅(px) + endpoint_margin_px: 有限線分判定における端点の許容量(px) """ self.line1 = line1 self.line2 = line2 self.parking_ref_point = parking_ref_point - self.margin = margin + self.margin_px = margin_px + self.endpoint_margin_px = endpoint_margin_px + self.line1_length = line_length(line1) + self.line2_length = line_length(line2) # 駐車場側の符号を事前計算 self.parking_side_line1 = side_of_line( @@ -77,14 +187,14 @@ def __init__(self, line2.end ) - def detect_line1_crossing(self, - prev_point: Tuple[float, float], + def update_line1_crossing(self, + transition_state: LineTransitionState, curr_point: Tuple[float, float]) -> Optional[str]: """ - Line1(入口側)の交差を検知 + Line1(入口側)の交差を判定し、ヒステリシス状態を更新する Args: - prev_point: 前フレームの車両位置 + transition_state: このtrack×Line1のヒステリシス状態(内部で更新される) curr_point: 現フレームの車両位置 Returns: @@ -93,21 +203,22 @@ def detect_line1_crossing(self, "OUT": 出庫方向に交差 None: 交差なし """ - return self._detect_crossing( - prev_point, + return self._update_crossing( + transition_state, curr_point, self.line1, - self.parking_side_line1 + self.parking_side_line1, + self.line1_length ) - def detect_line2_crossing(self, - prev_point: Tuple[float, float], + def update_line2_crossing(self, + transition_state: LineTransitionState, curr_point: Tuple[float, float]) -> Optional[str]: """ - Line2(駐車場側)の交差を検知 + Line2(駐車場側)の交差を判定し、ヒステリシス状態を更新する Args: - prev_point: 前フレームの車両位置 + transition_state: このtrack×Line2のヒステリシス状態(内部で更新される) curr_point: 現フレームの車両位置 Returns: @@ -116,26 +227,29 @@ def detect_line2_crossing(self, "OUT": 出庫方向に交差 None: 交差なし """ - return self._detect_crossing( - prev_point, + return self._update_crossing( + transition_state, curr_point, self.line2, - self.parking_side_line2 + self.parking_side_line2, + self.line2_length ) - def _detect_crossing(self, - prev_point: Tuple[float, float], + def _update_crossing(self, + transition_state: LineTransitionState, curr_point: Tuple[float, float], line: Line, - parking_side: float) -> Optional[str]: + parking_side: float, + line_len: float) -> Optional[str]: """ - ライン交差を検知(内部実装) + ヒステリシス方式によるライン交差判定(内部実装) Args: - prev_point: 前フレームの車両位置 + transition_state: ヒステリシス状態(内部で更新される) curr_point: 現フレームの車両位置 line: 判定するライン parking_side: 駐車場側の符号 + line_len: lineの長さ(px) Returns: Optional[str]: @@ -143,25 +257,46 @@ def _detect_crossing(self, "OUT": 出庫方向に交差 None: 交差なし """ - # 前フレームと現フレームの側を計算 - prev_side = side_of_line(prev_point, line.start, line.end) - curr_side = side_of_line(curr_point, line.start, line.end) + side = classify_side(curr_point, line, self.margin_px) + + if transition_state.stable_side is None: + # 初回観測。判定保留帯(0)なら何もせず、次フレームまで確定を待つ + if side != 0: + transition_state.stable_side = side + transition_state.last_stable_point = curr_point + return None - # マージン処理: 微小な揺れを無視 - if abs(prev_side) < self.margin or abs(curr_side) < self.margin: + if side == 0: + # 判定保留帯: 側もlast_stable_pointも変えない return None - # 符号が変化 = ライン交差 - if prev_side * curr_side < 0: - # 駐車場基準点でIN/OUT判定 - # curr_sideとparking_sideの符号が同じ = 駐車場側に移動 = IN - # curr_sideとparking_sideの符号が異なる = 道路側に移動 = OUT - if curr_side * parking_side > 0: - return "IN" - else: - return "OUT" + if side == transition_state.stable_side: + # 同じ安定側 → 参照点を更新するだけ + transition_state.last_stable_point = curr_point + return None - return None + # side == -stable_side: 反対の安定側へ抜けた + t = segment_crossing_param( + transition_state.last_stable_point, curr_point, line + ) + event = None + if t is not None: + e = (self.endpoint_margin_px / line_len) if line_len > 0 else 0.0 + if -e <= t <= 1.0 + e: + # 駐車場基準点でIN/OUT判定 + # sideとparking_sideの符号が同じ = 駐車場側に移動 = IN + # sideとparking_sideの符号が異なる = 道路側に移動 = OUT + event = "IN" if side * parking_side > 0 else "OUT" + + # ブックキーピングは判定の成否と無関係に更新する。 + # stable_sideは「curr_pointが現在どちらの安定側にいるか」という + # 幾何的事実であり、線分内かどうかとは独立している。ここで更新を + # 止めると、以降のフレームが古いlast_stable_pointを基準に判定を + # 続けてしまい、間に本物の交差があっても検出できなくなる。 + transition_state.stable_side = side + transition_state.last_stable_point = curr_point + + return event def is_point_near_line(self, point: Tuple[float, float], diff --git a/raspi/line_detection/detection/tracker.py b/raspi/line_detection/detection/tracker.py index 195b4a38..66ed2edb 100644 --- a/raspi/line_detection/detection/tracker.py +++ b/raspi/line_detection/detection/tracker.py @@ -7,6 +7,8 @@ from typing import Dict, Optional, Tuple, List import time +from detection.line_crossing import LineTransitionState + @dataclass class VehicleState: @@ -16,6 +18,10 @@ class VehicleState: prev_point: Optional[Tuple[float, float]] = None curr_point: Optional[Tuple[float, float]] = None + # ヒステリシス方式によるライン交差判定の状態(track×ラインごと) + line1_transition: LineTransitionState = field(default_factory=LineTransitionState) + line2_transition: LineTransitionState = field(default_factory=LineTransitionState) + # Line1状態(主判定ライン) line1_direction: Optional[str] = None # "IN" or "OUT" line1_frame: Optional[int] = None diff --git a/raspi/line_detection/line_setup/setup_lines.py b/raspi/line_detection/line_setup/setup_lines.py index 6306f6f6..002c649e 100755 --- a/raspi/line_detection/line_setup/setup_lines.py +++ b/raspi/line_detection/line_setup/setup_lines.py @@ -257,7 +257,8 @@ def save_to_env(self): PARKING_REF_Y={self.points[4][1]} # 検知パラメータ -MARGIN=1000.0 +MARGIN_PX=0.0 +ENDPOINT_MARGIN_PX=0.0 MAX_FRAME_GAP=90 # 3秒@30fps CLEANUP_THRESHOLD=150 # 5秒@30fps diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index a8989885..edff8443 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -57,6 +57,7 @@ WEBCAM_FPS = 30.0 +CROSSING_METHOD = "hysteresis_v1" # W&B上でPre/Post-3bのrunを区別する固定タグ(可変設定ではない) @dataclass(frozen=True) @@ -101,7 +102,9 @@ def from_env(cls) -> "RuntimeSettings": "gt_out", "vehicle_classes", "method", - "margin", + "margin_px", + "endpoint_margin_px", + "crossing_method", "max_frame_gap", "cleanup_threshold", "tracker_reset", @@ -199,9 +202,10 @@ def process_video( line1=config.line1, line2=config.line2, parking_ref_point=config.parking_ref_point, - margin=config.margin + margin_px=config.margin_px, + endpoint_margin_px=config.endpoint_margin_px ) - print(f"✓ ライン検知器初期化: margin={config.margin}") + print(f"✓ ライン検知器初期化: margin_px={config.margin_px}, endpoint_margin_px={config.endpoint_margin_px}") # アノテーターを初期化 annotator = VideoAnnotator( @@ -235,8 +239,10 @@ def process_video( dataset = Path(str(video_path)).stem if isinstance(video_path, str) else f"camera_{video_path}" exp_params = { "cleanup_threshold": config.cleanup_threshold, - "margin": config.margin, + "margin_px": config.margin_px, + "endpoint_margin_px": config.endpoint_margin_px, "max_frame_gap": config.max_frame_gap, + "crossing_method": CROSSING_METHOD, } reproducibility = collect_reproducibility_info() run_config = { @@ -339,14 +345,12 @@ def process_video( for track_id, bbox in detections: vehicle_point = get_vehicle_point(bbox) state = tracker.update(track_id, vehicle_point, frame_id) - if state.prev_point is None: - continue - line1_dir = detector.detect_line1_crossing( - state.prev_point, state.curr_point + line1_dir = detector.update_line1_crossing( + state.line1_transition, state.curr_point ) - line2_dir = detector.detect_line2_crossing( - state.prev_point, state.curr_point + line2_dir = detector.update_line2_crossing( + state.line2_transition, state.curr_point ) if line1_dir and not state.counted: state.record_line1_crossing(line1_dir, frame_id) diff --git a/raspi/line_detection/tests/test_ground_truth_integration.py b/raspi/line_detection/tests/test_ground_truth_integration.py index e0732fec..57695da0 100644 --- a/raspi/line_detection/tests/test_ground_truth_integration.py +++ b/raspi/line_detection/tests/test_ground_truth_integration.py @@ -34,7 +34,9 @@ "gt_out": 0, "vehicle_classes": [2, 7], "method": "hybrid", - "margin": 1000.0, + "margin_px": 0.0, + "endpoint_margin_px": 0.0, + "crossing_method": "hysteresis_v1", "max_frame_gap": 90, "cleanup_threshold": 150, "yolo_conf": 0.3, diff --git a/raspi/line_detection/tests/test_line_crossing.py b/raspi/line_detection/tests/test_line_crossing.py new file mode 100644 index 00000000..ff800265 --- /dev/null +++ b/raspi/line_detection/tests/test_line_crossing.py @@ -0,0 +1,211 @@ +import sys +from pathlib import Path + +import pytest + + +LINE_ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(LINE_ROOT)) + +from detection.config import Line +from detection.line_crossing import ( + LineCrossingDetector, + LineTransitionState, + classify_side, + line_length, + segment_crossing_param, + side_of_line, + signed_distance, +) + + +LINE = Line(start=(0, 0), end=(100, 0)) + + +# --- 幾何関数の単体テスト ------------------------------------------------- + + +def test_line_length_horizontal(): + assert line_length(LINE) == pytest.approx(100.0) + + +def test_signed_distance_is_px_invariant_to_line_length(): + short_line = Line(start=(0, 0), end=(100, 0)) + long_line = Line(start=(0, 0), end=(400, 0)) + # 短い/長いラインそれぞれから同じ物理的な5px離れた点 + d_short = signed_distance((50, 5), short_line) + d_long = signed_distance((200, 5), long_line) + assert abs(d_short) == pytest.approx(5.0) + assert abs(d_long) == pytest.approx(5.0) + + +def test_signed_distance_sign_matches_side_of_line(): + point_above = (50, 10) + point_below = (50, -10) + assert (signed_distance(point_above, LINE) > 0) == ( + side_of_line(point_above, LINE.start, LINE.end) > 0 + ) + assert (signed_distance(point_below, LINE) > 0) == ( + side_of_line(point_below, LINE.start, LINE.end) > 0 + ) + + +def test_classify_side_stable_positive(): + assert classify_side((50, 10), LINE, margin_px=5) == 1 + + +def test_classify_side_stable_negative(): + assert classify_side((50, -10), LINE, margin_px=5) == -1 + + +def test_classify_side_dead_zone(): + assert classify_side((50, 3), LINE, margin_px=5) == 0 + + +def test_classify_side_boundary_is_dead_zone(): + # d == margin_px はどちらの安定側でもなく判定保留帯(境界含む) + assert classify_side((50, 5.0), LINE, margin_px=5) == 0 + assert classify_side((50, -5.0), LINE, margin_px=5) == 0 + + +def test_segment_crossing_param_interior(): + t = segment_crossing_param((40, -20), (40, 30), LINE) + assert t == pytest.approx(0.40) + + +def test_segment_crossing_param_beyond_b_endpoint(): + t = segment_crossing_param((150, -20), (150, 30), LINE) + assert t == pytest.approx(1.50) + + +def test_segment_crossing_param_before_a_endpoint(): + t = segment_crossing_param((-30, -20), (-30, 30), LINE) + assert t == pytest.approx(-0.30) + + +def test_segment_crossing_param_same_side_returns_none(): + assert segment_crossing_param((40, 10), (60, 20), LINE) is None + + +def test_segment_crossing_param_on_boundary_same_side_returns_none(): + # 片方がライン上(d=0)で反対側の符号ではない場合はNone + assert segment_crossing_param((40, 0), (60, 10), LINE) is None + + +def test_endpoint_margin_widens_valid_range(): + # t=1.50 のケースを、endpoint_margin_pxを介した許容範囲で判定する + t = segment_crossing_param((150, -20), (150, 30), LINE) + line_len = line_length(LINE) + + e_narrow = 0.0 / line_len + assert not (-e_narrow <= t <= 1.0 + e_narrow) + + e_wide = 60.0 / line_len + assert -e_wide <= t <= 1.0 + e_wide + + +def test_side_of_line_still_exported(): + # visualize_lines_and_vehicles.py が直接importして使うため、 + # 削除されていないことを回帰確認する + value = side_of_line((50, 10), LINE.start, LINE.end) + assert isinstance(value, float) + + +# --- ヒステリシス統合テスト(LineCrossingDetector + LineTransitionState) ---- + + +def make_detector(margin_px=5.0, endpoint_margin_px=0.0): + return LineCrossingDetector( + line1=LINE, + line2=Line(start=(0, 200), end=(100, 200)), + parking_ref_point=(50, 100), # LINEに対してy>0側 = IN方向 + margin_px=margin_px, + endpoint_margin_px=endpoint_margin_px, + ) + + +def test_hysteresis_jitter_inside_dead_zone_never_crosses(): + detector = make_detector(margin_px=5) + state = LineTransitionState() + points = [(50, -12), (50, -2), (50, 3), (50, -1), (50, -9)] + + results = [detector.update_line1_crossing(state, p) for p in points] + + assert results == [None, None, None, None, None] + assert state.stable_side == -1 + + +def test_hysteresis_slow_crossing_detected_exactly_once(): + detector = make_detector(margin_px=5) + state = LineTransitionState() + points = [(50, -12), (50, -3), (50, 1), (50, 4), (50, 11), (50, 18)] + + results = [detector.update_line1_crossing(state, p) for p in points] + + assert results == [None, None, None, None, "IN", None] + + +def test_hysteresis_large_single_frame_jump_still_detected(): + detector = make_detector(margin_px=5) + state = LineTransitionState() + + assert detector.update_line1_crossing(state, (50, -12)) is None + assert detector.update_line1_crossing(state, (50, 18)) == "IN" + + +def test_hysteresis_extension_flip_reports_no_event_but_updates_stable_side(): + detector = make_detector(margin_px=5, endpoint_margin_px=0.0) + state = LineTransitionState() + + assert detector.update_line1_crossing(state, (-30, -20)) is None + assert state.stable_side == -1 + + result = detector.update_line1_crossing(state, (-30, 30)) + + assert result is None + assert state.stable_side == 1 + assert state.last_stable_point == (-30, 30) + + +def test_hysteresis_extension_flip_then_real_crossing_detected(): + detector = make_detector(margin_px=5, endpoint_margin_px=0.0) + state = LineTransitionState() + + detector.update_line1_crossing(state, (-30, -20)) + detector.update_line1_crossing(state, (-30, 30)) # 延長線での反転(不成立) + + result = detector.update_line1_crossing(state, (50, -20)) + + assert result == "OUT" + + +def test_hysteresis_never_initializes_from_dead_zone_point(): + detector = make_detector(margin_px=5) + state = LineTransitionState() + + assert detector.update_line1_crossing(state, (50, 3)) is None + assert state.stable_side is None + + assert detector.update_line1_crossing(state, (50, 10)) is None + assert state.stable_side == 1 + + +def test_hysteresis_endpoint_margin_zero_rejects_extension_crossing(): + detector = make_detector(margin_px=5, endpoint_margin_px=0.0) + state = LineTransitionState() + + detector.update_line1_crossing(state, (-30, -20)) + result = detector.update_line1_crossing(state, (-30, 30)) + + assert result is None + + +def test_hysteresis_endpoint_margin_widened_accepts_near_extension_crossing(): + # t=-0.30 の交差(line_length=100) を受理するには e>=0.3 が必要 + detector = make_detector(margin_px=5, endpoint_margin_px=40.0) + state = LineTransitionState() + + detector.update_line1_crossing(state, (-30, -20)) + result = detector.update_line1_crossing(state, (-30, 30)) + + assert result == "IN" diff --git a/raspi/line_detection/tests/test_run_identity_integration.py b/raspi/line_detection/tests/test_run_identity_integration.py index 812ee2d2..923da45e 100644 --- a/raspi/line_detection/tests/test_run_identity_integration.py +++ b/raspi/line_detection/tests/test_run_identity_integration.py @@ -30,7 +30,9 @@ "parking_reference_point": [5.0, 20.0], "vehicle_classes": [2, 7], "method": "hybrid", - "margin": 1000.0, + "margin_px": 0.0, + "endpoint_margin_px": 0.0, + "crossing_method": "hysteresis_v1", "max_frame_gap": 90, "cleanup_threshold": 150, "yolo_conf": 0.3, diff --git a/raspi/line_detection/tests/test_tracker.py b/raspi/line_detection/tests/test_tracker.py new file mode 100644 index 00000000..b324cc1d --- /dev/null +++ b/raspi/line_detection/tests/test_tracker.py @@ -0,0 +1,171 @@ +import sys +from pathlib import Path + + +LINE_ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(LINE_ROOT)) + +from detection.config import Line +from detection.line_crossing import LineCrossingDetector +from detection.tracker import VehicleTracker + + +# Line1(入口側、y=0)→Line2(駐車場側、y=50)→駐車場基準点(y=100)の順に並ぶ、 +# main.py の実運用構成を模した幾何。両ラインとも「yが大きい側=駐車場側=IN」で揃える。 +LINE1 = Line(start=(0, 0), end=(100, 0)) +LINE2 = Line(start=(0, 50), end=(100, 50)) +PARKING_REF_POINT = (50, 100) + + +def make_tracker_and_detector(margin_px=5.0, endpoint_margin_px=0.0, + max_frame_gap=90, cleanup_threshold=150): + detector = LineCrossingDetector( + line1=LINE1, + line2=LINE2, + parking_ref_point=PARKING_REF_POINT, + margin_px=margin_px, + endpoint_margin_px=endpoint_margin_px, + ) + tracker = VehicleTracker(max_frame_gap=max_frame_gap, cleanup_threshold=cleanup_threshold) + return tracker, detector + + +def process_frame(tracker, detector, track_id, point, frame_id): + """main.py の per-frame ループ本体(cleanup呼び出しを除く)を1track分再現する。""" + state = tracker.update(track_id, point, frame_id) + + line1_dir = detector.update_line1_crossing(state.line1_transition, state.curr_point) + line2_dir = detector.update_line2_crossing(state.line2_transition, state.curr_point) + if line1_dir and not state.counted: + state.record_line1_crossing(line1_dir, frame_id) + if line2_dir: + state.record_line2_crossing(line2_dir, frame_id) + if tracker.should_count_event(state): + tracker.mark_as_counted(track_id) + + return state + + +def test_jitter_across_line1_does_not_double_count(): + tracker, detector = make_tracker_and_detector(margin_px=5) + points = [(50, -12), (50, -2), (50, 3), (50, -1), (50, -9)] + + for frame_id, point in enumerate(points): + process_frame(tracker, detector, track_id=1, point=point, frame_id=frame_id) + + assert tracker.total_in == 0 + assert tracker.total_out == 0 + + +def test_slow_crossing_across_line1_counts_exactly_once(): + tracker, detector = make_tracker_and_detector(margin_px=5) + points = [(50, -12), (50, -3), (50, 1), (50, 4), (50, 11), (50, 18)] + + state = None + for frame_id, point in enumerate(points): + state = process_frame(tracker, detector, track_id=1, point=point, frame_id=frame_id) + + assert tracker.total_in == 1 + assert state.counted is True + + # 同じ側に留まったまま再度フレームを処理しても二重カウントしない + process_frame(tracker, detector, track_id=1, point=(50, 18), frame_id=len(points)) + assert tracker.total_in == 1 + + +def test_line2_jitter_does_not_corrupt_passed_order(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + # Line1をゆっくり通過してINを確定させる + line1_crossing_points = [(50, -12), (50, -3), (50, 1), (50, 4), (50, 11)] + for frame_id, point in enumerate(line1_crossing_points): + state = process_frame(tracker, detector, track_id=1, point=point, frame_id=frame_id) + assert state.passed_order == ["line1"] + + # Line2の安定側(道路側)へ到達したのち、判定保留帯の中で揺れる + next_frame_id = len(line1_crossing_points) + line2_jitter_points = [(50, 44), (50, 47), (50, 53), (50, 48), (50, 52), (50, 46)] + for offset, point in enumerate(line2_jitter_points): + state = process_frame( + tracker, detector, track_id=1, point=point, frame_id=next_frame_id + offset + ) + + assert state.passed_order == ["line1"] + assert state.line2_direction is None + + +def test_in_direction_line1_then_line2_slow_crossing_matches_expected_order(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + line1_crossing_points = [(50, -12), (50, -3), (50, 1), (50, 4), (50, 11)] + for frame_id, point in enumerate(line1_crossing_points): + state = process_frame(tracker, detector, track_id=1, point=point, frame_id=frame_id) + + next_frame_id = len(line1_crossing_points) + line2_crossing_points = [(50, 44), (50, 47), (50, 51), (50, 54), (50, 61)] + for offset, point in enumerate(line2_crossing_points): + state = process_frame( + tracker, detector, track_id=1, point=point, frame_id=next_frame_id + offset + ) + + assert state.passed_order == ["line1", "line2"] + assert state.line1_direction == "IN" + assert state.line2_direction == "IN" + + +def test_track_id_reuse_after_cleanup_gets_fresh_transition_state(): + tracker, detector = make_tracker_and_detector(margin_px=5, cleanup_threshold=10) + + process_frame(tracker, detector, track_id=1, point=(50, -12), frame_id=0) + old_state = tracker.get_state(1) + assert old_state.line1_transition.stable_side == -1 + + tracker.cleanup_stale_tracks(current_frame=0 + tracker.cleanup_threshold + 1) + assert tracker.get_state(1) is None + + # 同じ数値のtrack_idが再度現れても、古いstable_sideを引き継がない。 + # もし引き継いでいたら(stale -1のまま)、(50,80)への到達は「反対側への + # 反転」とみなされ、その場でIN判定が発火してしまう。 + new_state = process_frame(tracker, detector, track_id=1, point=(50, 80), frame_id=100) + + assert new_state is not old_state + assert new_state.line1_direction is None # 反転扱いされていない(=初回観測扱い) + assert new_state.line1_transition.stable_side == 1 + + +def test_cleanup_threshold_boundary_exact_and_plus_one(): + tracker, _ = make_tracker_and_detector(cleanup_threshold=10) + tracker.update(track_id=1, point=(0, 0), frame_id=0) + + tracker.cleanup_stale_tracks(current_frame=10) + assert tracker.get_state(1) is not None + + tracker.cleanup_stale_tracks(current_frame=11) + assert tracker.get_state(1) is None + + +def test_frame_id_zero_line1_crossing_recorded(): + # ヒステリシス方式では初回観測フレーム自体では交差が確定しない設計のため + # (side反転の相手が存在しないため)、frame_id=0での交差確定そのものは + # 起こり得ない。ここでは record_line1_crossing 以降のカウント経路が + # line1_frame の値(0はfalsy)に依存していないことを直接確認する。 + tracker, _ = make_tracker_and_detector() + state = tracker.update(track_id=1, point=(50, 20), frame_id=0) + state.record_line1_crossing("IN", frame_id=0) + + assert state.line1_frame == 0 + assert tracker.should_count_event(state) is True + + event_type = tracker.mark_as_counted(1) + + assert event_type == "IN" + assert tracker.total_in == 1 + + +def test_large_frame_gap_single_jump_across_line1_still_counts(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + process_frame(tracker, detector, track_id=1, point=(50, -12), frame_id=0) + process_frame(tracker, detector, track_id=1, point=(50, 18), frame_id=50) + + assert tracker.total_in == 1 diff --git a/raspi/line_detection/visualize_lines_and_vehicles.py b/raspi/line_detection/visualize_lines_and_vehicles.py index 5d1c51f9..487cde6c 100644 --- a/raspi/line_detection/visualize_lines_and_vehicles.py +++ b/raspi/line_detection/visualize_lines_and_vehicles.py @@ -12,7 +12,7 @@ sys.path.insert(0, os.path.dirname(__file__)) from detection.config import Config -from detection.line_crossing import get_vehicle_point, side_of_line +from detection.line_crossing import get_vehicle_point, side_of_line, signed_distance def visualize(video_path: str, config: Config, output_path: str = None, start_frame: int = 0, end_frame: int = 300): @@ -34,7 +34,7 @@ def visualize(video_path: str, config: Config, output_path: str = None, start_fr print(f"Line1: {config.line1.start} → {config.line1.end}") print(f"Line2: {config.line2.start} → {config.line2.end}") print(f"駐車場基準点: {config.parking_ref_point}") - print(f"MARGIN: {config.margin}") + print(f"MARGIN_PX: {config.margin_px}, ENDPOINT_MARGIN_PX: {config.endpoint_margin_px}") print("=" * 80 + "\n") # YOLOモデルをロード @@ -149,13 +149,13 @@ def visualize(video_path: str, config: Config, output_path: str = None, start_fr -1 ) - # Line1との外積を計算 - side1 = side_of_line(vehicle_point, config.line1.start, config.line1.end) - side2 = side_of_line(vehicle_point, config.line2.start, config.line2.end) + # Line1/Line2からの符号付き距離(px)を計算 + side1 = signed_distance(vehicle_point, config.line1) + side2 = signed_distance(vehicle_point, config.line2) - # 外積値とMARGIN比較を表示 - status1 = "OK" if abs(side1) >= config.margin else f"小({abs(side1):.0f}<{config.margin})" - status2 = "OK" if abs(side2) >= config.margin else f"小({abs(side2):.0f}<{config.margin})" + # 符号付き距離とMARGIN_PX比較を表示 + status1 = "OK" if abs(side1) >= config.margin_px else f"小({abs(side1):.1f}<{config.margin_px})" + status2 = "OK" if abs(side2) >= config.margin_px else f"小({abs(side2):.1f}<{config.margin_px})" # 駐車場側かどうか is_parking_side1 = "駐車場側" if side1 * parking_side_line1 > 0 else "入口側" @@ -164,8 +164,8 @@ def visualize(video_path: str, config: Config, output_path: str = None, start_fr # 情報を表示 info_text = [ f"ID:{track_id}", - f"L1:{side1:.0f} [{status1}] {is_parking_side1}", - f"L2:{side2:.0f} [{status2}] {is_parking_side2}" + f"L1:{side1:.1f} [{status1}] {is_parking_side1}", + f"L2:{side2:.1f} [{status2}] {is_parking_side2}" ] y_offset = int(vehicle_point[1]) - 20 From c3292404277990ac600f3709023459fae82cc705 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:01:32 +0900 Subject: [PATCH 41/57] =?UTF-8?q?feat:=20Line2=E9=80=9A=E9=81=8E=E5=BE=8C?= =?UTF-8?q?=E3=81=ABconfidence=E3=82=92=E7=A2=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/detection/tracker.py | 128 +++++++++++++----- raspi/line_detection/main.py | 35 +++++ .../result_output/event_logger.py | 56 +++++++- .../line_detection/tests/test_event_logger.py | 36 +++++ raspi/line_detection/tests/test_tracker.py | 127 +++++++++++++++++ 5 files changed, 348 insertions(+), 34 deletions(-) diff --git a/raspi/line_detection/detection/tracker.py b/raspi/line_detection/detection/tracker.py index 66ed2edb..b55ae5f1 100644 --- a/raspi/line_detection/detection/tracker.py +++ b/raspi/line_detection/detection/tracker.py @@ -4,12 +4,25 @@ """ from dataclasses import dataclass, field -from typing import Dict, Optional, Tuple, List +from typing import Dict, Literal, Optional, Tuple, List import time from detection.line_crossing import LineTransitionState +Confidence = Literal["pending", "high", "normal"] +FinalConfidence = Literal["high", "normal"] + + +@dataclass(frozen=True) +class ConfidenceUpdate: + """イベントログへ反映する確定済みconfidence。""" + + track_id: int + confidence: FinalConfidence + line2_crossed: bool + + @dataclass class VehicleState: """車両の状態を保持するデータクラス""" @@ -35,7 +48,7 @@ class VehicleState: # イベント追跡 passed_order: List[str] = field(default_factory=list) # ["line1", "line2"] など counted: bool = False - confidence: Optional[str] = None # "high" or "normal" + confidence: Optional[Confidence] = None # 更新追跡 last_update_frame: int = 0 @@ -78,45 +91,55 @@ def record_line2_crossing(self, direction: str, frame_id: int): self.line2_timestamp = time.time() self.passed_order.append("line2") - def calculate_confidence(self, max_frame_gap: int) -> Optional[str]: + def resolve_confidence( + self, + current_frame: int, + max_frame_gap: int, + ) -> Optional[Confidence]: """ - 信頼度を計算(ハイブリッド方式) + pendingの信頼度を、現在までのライン通過履歴から解決する。 Args: + current_frame: 現在のフレーム番号 max_frame_gap: Line1とLine2の最大フレーム差 Returns: - Optional[str]: "high" or "normal" or None + Optional[Confidence]: "pending"、"high"、"normal"、またはNone """ - # Line1を交差していない場合は信頼度なし - if not self.line1_frame: + if not self.counted or self.line1_frame is None: return None - # Line2を交差していない場合はnormal - if not self.line2_direction: - return "normal" + if self.confidence in ("high", "normal"): + return self.confidence - # Line1とLine2の方向が一致しているかチェック - if self.line1_direction != self.line2_direction: - return "normal" + if self.line1_direction == "IN" and self.line2_direction is None: + if current_frame - self.line1_frame > max_frame_gap: + self.confidence = "normal" + else: + self.confidence = "pending" + return self.confidence - # フレーム差をチェック - frame_diff = abs(self.line1_frame - self.line2_frame) - if frame_diff > max_frame_gap: - return "normal" + # OUTでLine2が未通過なら、期待順序 line2→line1 はすでに成立しない。 + if self.line2_direction is None or self.line2_frame is None: + self.confidence = "normal" + return self.confidence - # 通過順序をチェック if self.line1_direction == "IN": - # 入庫の場合: line1 → line2 が正しい順序 expected_order = ["line1", "line2"] - else: # OUT - # 出庫の場合: line2 → line1 が正しい順序 + elif self.line1_direction == "OUT": expected_order = ["line2", "line1"] - - if self.passed_order == expected_order: - return "high" else: - return "normal" + self.confidence = "normal" + return self.confidence + + frame_diff = abs(self.line1_frame - self.line2_frame) + valid_pair = ( + self.line1_direction == self.line2_direction + and frame_diff <= max_frame_gap + and self.passed_order == expected_order + ) + self.confidence = "high" if valid_pair else "normal" + return self.confidence class VehicleTracker: @@ -207,11 +230,9 @@ def mark_as_counted(self, track_id: int) -> Optional[str]: if not state: return None - # 信頼度を計算 - state.confidence = state.calculate_confidence(self.max_frame_gap) - # カウント済みフラグを設定 state.counted = True + state.confidence = "pending" # 統計情報を更新 if state.line1_direction == "IN": @@ -219,13 +240,51 @@ def mark_as_counted(self, track_id: int) -> Optional[str]: elif state.line1_direction == "OUT": self.total_out += 1 - if state.confidence == "high": + return state.line1_direction + + def resolve_pending_confidences( + self, + current_frame: int, + ) -> List[ConfidenceUpdate]: + """全trackのpending confidenceを評価し、新たな確定結果を返す。""" + updates = [] + for state in self.states.values(): + if state.confidence != "pending": + continue + + resolved = state.resolve_confidence(current_frame, self.max_frame_gap) + if resolved in ("high", "normal"): + self._record_confidence_resolution(resolved) + updates.append(ConfidenceUpdate( + track_id=state.track_id, + confidence=resolved, + line2_crossed=state.line2_direction is not None, + )) + return updates + + def finalize_pending_confidences(self) -> List[ConfidenceUpdate]: + """run終了時に残ったpending confidenceをnormalへ確定する。""" + updates = [] + for state in self.states.values(): + if state.confidence != "pending": + continue + + state.confidence = "normal" + self._record_confidence_resolution("normal") + updates.append(ConfidenceUpdate( + track_id=state.track_id, + confidence="normal", + line2_crossed=state.line2_direction is not None, + )) + return updates + + def _record_confidence_resolution(self, confidence: FinalConfidence): + """確定時に一度だけconfidence別件数を加算する。""" + if confidence == "high": self.high_confidence_count += 1 - elif state.confidence == "normal": + else: self.normal_confidence_count += 1 - return state.line1_direction - def cleanup_stale_tracks(self, current_frame: int): """ 古い追跡を削除 @@ -238,7 +297,10 @@ def cleanup_stale_tracks(self, current_frame: int): for track_id, state in self.states.items(): frames_since_update = current_frame - state.last_update_frame - if frames_since_update > self.cleanup_threshold: + if ( + frames_since_update > self.cleanup_threshold + and state.confidence != "pending" + ): track_ids_to_remove.append(track_id) for track_id in track_ids_to_remove: diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index edff8443..767a20c7 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -366,6 +366,7 @@ def process_video( "confidence": state.confidence, "line2_crossed": state.line2_direction is not None, }) + confidence_updates = tracker.resolve_pending_confidences(frame_id) tracker.cleanup_stale_tracks(frame_id) core_ms = inference_timer.elapsed_ms + counting_timer.elapsed_ms @@ -373,6 +374,20 @@ def process_video( with elapsed_timer() as output_timer: for event in pending_events: event_logger.record_event(**event) + for update in confidence_updates: + if not event_logger.update_confidence( + update.track_id, + update.confidence, + line2_crossed=update.line2_crossed, + ): + raise RuntimeError( + "confidence更新対象のイベントが見つかりません: " + f"track_id={update.track_id}" + ) + for event in pending_events: + if event["track_id"] == update.track_id: + event["confidence"] = update.confidence + event["line2_crossed"] = update.line2_crossed if config.save_video or config.show_display: annotated_frame = annotator.annotate_frame( frame, tracker, frame_id, core_ms @@ -442,9 +457,27 @@ def process_video( print("\nユーザーによる中断") break + final_confidence_updates = tracker.finalize_pending_confidences() + for update in final_confidence_updates: + if not event_logger.update_confidence( + update.track_id, + update.confidence, + line2_crossed=update.line2_crossed, + ): + raise RuntimeError( + "終了時confidence更新対象のイベントが見つかりません: " + f"track_id={update.track_id}" + ) + orphan_pending = event_logger.finalize_pending_confidences() + if orphan_pending: + raise RuntimeError( + f"trackerに存在しないpendingイベントが残っています: {orphan_pending}件" + ) + measured = require_measured_timings(timing_records) timing_stats = compute_timing_stats(measured, float(fps)) tracker_summary = tracker.get_summary() + event_logger.validate_finalized(tracker_summary) accuracy_summary = build_ground_truth_summary( tracker_summary["total_in"], tracker_summary["total_out"], gt ) @@ -453,6 +486,8 @@ def process_video( "count_out": tracker_summary["total_out"], "total_frames": frame_id, "measured_frames": len(measured), + "high_confidence_events": tracker_summary["high_confidence_events"], + "normal_confidence_events": tracker_summary["normal_confidence_events"], **accuracy_summary, **timing_stats, }) diff --git a/raspi/line_detection/result_output/event_logger.py b/raspi/line_detection/result_output/event_logger.py index bdb20986..4cf416c9 100644 --- a/raspi/line_detection/result_output/event_logger.py +++ b/raspi/line_detection/result_output/event_logger.py @@ -7,7 +7,7 @@ import csv import os from datetime import datetime -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional class Event: @@ -93,6 +93,60 @@ def record_event(self, self.events.append(event) + def update_confidence( + self, + track_id: int, + confidence: str, + line2_crossed: Optional[bool] = None, + ) -> bool: + """track_idで既存イベントを検索し、後追いの確定結果を反映する。""" + matched = [event for event in self.events if event.track_id == track_id] + if not matched: + return False + if len(matched) > 1: + raise ValueError(f"同一track_idのイベントが重複しています: {track_id}") + + event = matched[0] + event.confidence = confidence + if line2_crossed is not None: + event.line2_crossed = line2_crossed + return True + + def finalize_pending_confidences(self) -> int: + """保存前に残ったpendingイベントをnormalへ確定する。""" + finalized = 0 + for event in self.events: + if event.confidence == "pending": + event.confidence = "normal" + finalized += 1 + return finalized + + def validate_finalized(self, tracker_summary: Dict[str, Any]) -> None: + """イベントとtracker集計の整合性を検証する。""" + pending = [event.track_id for event in self.events if event.confidence == "pending"] + if pending: + raise ValueError(f"pendingイベントが保存直前まで残っています: {pending}") + + expected_events = tracker_summary["total_in"] + tracker_summary["total_out"] + if len(self.events) != expected_events: + raise ValueError( + "イベント数とカウント数が一致しません: " + f"events={len(self.events)}, counts={expected_events}" + ) + + high = sum(event.confidence == "high" for event in self.events) + normal = sum(event.confidence == "normal" for event in self.events) + if (high, normal) != ( + tracker_summary["high_confidence_events"], + tracker_summary["normal_confidence_events"], + ): + raise ValueError( + "confidence集計とイベント列が一致しません: " + f"events=({high}, {normal}), " + f"summary=({tracker_summary['high_confidence_events']}, " + f"{tracker_summary['normal_confidence_events']})" + ) + def record_frame_time(self, processing_time_ms: float): """ フレーム処理時間を記録 diff --git a/raspi/line_detection/tests/test_event_logger.py b/raspi/line_detection/tests/test_event_logger.py index bc2deee6..c13edb7c 100644 --- a/raspi/line_detection/tests/test_event_logger.py +++ b/raspi/line_detection/tests/test_event_logger.py @@ -79,3 +79,39 @@ def test_save_json_omits_accuracy_block_when_absent(tmp_path): data = json.loads(Path(path).read_text(encoding="utf-8")) assert "accuracy" not in data + + +def test_update_confidence_updates_event_and_line2_crossing(): + logger = EventLogger(video_path="test.mp4") + logger.record_event( + track_id=7, + event_type="IN", + frame_id=10, + fps=30.0, + confidence="pending", + line2_crossed=False, + ) + + assert logger.update_confidence(7, "high", line2_crossed=True) is True + assert logger.events[0].confidence == "high" + assert logger.events[0].line2_crossed is True + + +def test_update_confidence_returns_false_for_unknown_track(): + logger = EventLogger(video_path="test.mp4") + assert logger.update_confidence(999, "normal") is False + + +def test_finalize_pending_confidences_and_validate_summary(): + logger = EventLogger(video_path="test.mp4") + logger.record_event(1, "IN", 1, 30.0, "pending", False) + logger.record_event(2, "OUT", 2, 30.0, "high", True) + + assert logger.finalize_pending_confidences() == 1 + summary = { + "total_in": 1, + "total_out": 1, + "high_confidence_events": 1, + "normal_confidence_events": 1, + } + logger.validate_finalized(summary) diff --git a/raspi/line_detection/tests/test_tracker.py b/raspi/line_detection/tests/test_tracker.py index b324cc1d..d6a0443a 100644 --- a/raspi/line_detection/tests/test_tracker.py +++ b/raspi/line_detection/tests/test_tracker.py @@ -42,6 +42,7 @@ def process_frame(tracker, detector, track_id, point, frame_id): state.record_line2_crossing(line2_dir, frame_id) if tracker.should_count_event(state): tracker.mark_as_counted(track_id) + tracker.resolve_pending_confidences(frame_id) return state @@ -169,3 +170,129 @@ def test_large_frame_gap_single_jump_across_line1_still_counts(): process_frame(tracker, detector, track_id=1, point=(50, 18), frame_id=50) assert tracker.total_in == 1 + + +def test_in_line1_then_line2_resolves_high_after_line2(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + points = [ + (50, -12), (50, -3), (50, 1), (50, 4), (50, 11), + (50, 44), (50, 47), (50, 51), (50, 54), (50, 61), + ] + states = [] + for frame_id, point in enumerate(points): + states.append(process_frame(tracker, detector, 1, point, frame_id)) + + state = states[-1] + assert state.confidence == "high" + assert tracker.total_in == 1 + assert tracker.high_confidence_count == 1 + assert tracker.normal_confidence_count == 0 + + +def test_out_line2_then_line1_resolves_high_on_line1_frame(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + points = [(50, 61), (50, 39), (50, 11), (50, -11)] + for frame_id, point in enumerate(points): + state = process_frame(tracker, detector, 1, point, frame_id) + + assert state.line2_direction == "OUT" + assert state.line1_direction == "OUT" + assert state.passed_order == ["line2", "line1"] + assert state.confidence == "high" + assert tracker.total_out == 1 + assert tracker.high_confidence_count == 1 + + +def test_pending_in_resolves_normal_after_gap_exceeds_boundary(): + tracker, _ = make_tracker_and_detector(max_frame_gap=3) + state = tracker.update(1, (50, 10), frame_id=0) + state.record_line1_crossing("IN", frame_id=0) + tracker.mark_as_counted(1) + + assert tracker.resolve_pending_confidences(3) == [] + assert state.confidence == "pending" + + updates = tracker.resolve_pending_confidences(4) + assert [(u.track_id, u.confidence) for u in updates] == [(1, "normal")] + assert tracker.normal_confidence_count == 1 + + +def test_reverse_order_in_is_normal(): + tracker, _ = make_tracker_and_detector() + state = tracker.update(1, (50, 10), frame_id=0) + state.record_line2_crossing("IN", frame_id=0) + state.record_line1_crossing("IN", frame_id=1) + tracker.mark_as_counted(1) + + updates = tracker.resolve_pending_confidences(1) + assert [(u.track_id, u.confidence) for u in updates] == [(1, "normal")] + + +def test_direction_mismatch_is_normal(): + tracker, _ = make_tracker_and_detector() + state = tracker.update(1, (50, 10), frame_id=0) + state.record_line1_crossing("IN", frame_id=0) + state.record_line2_crossing("OUT", frame_id=1) + tracker.mark_as_counted(1) + + assert state.resolve_confidence(1, 90) == "normal" + + +def test_duplicate_line2_before_line1_is_normal_for_out(): + tracker, _ = make_tracker_and_detector() + state = tracker.update(1, (50, 10), frame_id=0) + state.record_line2_crossing("OUT", frame_id=0) + state.record_line2_crossing("OUT", frame_id=1) + state.record_line1_crossing("OUT", frame_id=2) + tracker.mark_as_counted(1) + + assert state.resolve_confidence(2, 90) == "normal" + + +def test_correct_pair_beyond_gap_is_normal(): + tracker, _ = make_tracker_and_detector(max_frame_gap=3) + state = tracker.update(1, (50, 10), frame_id=0) + state.record_line1_crossing("IN", frame_id=0) + state.record_line2_crossing("IN", frame_id=4) + tracker.mark_as_counted(1) + + assert state.resolve_confidence(4, 3) == "normal" + + +def test_pending_state_survives_cleanup_until_confidence_is_resolved(): + tracker, _ = make_tracker_and_detector(max_frame_gap=90, cleanup_threshold=10) + state = tracker.update(1, (50, 10), frame_id=0) + state.record_line1_crossing("IN", frame_id=0) + tracker.mark_as_counted(1) + + tracker.cleanup_stale_tracks(11) + assert tracker.get_state(1) is state + + updates = tracker.resolve_pending_confidences(91) + assert updates[0].confidence == "normal" + tracker.cleanup_stale_tracks(91) + assert tracker.get_state(1) is None + + +def test_finalize_pending_confidences_marks_remaining_events_normal(): + tracker, _ = make_tracker_and_detector() + state = tracker.update(1, (50, 10), frame_id=0) + state.record_line1_crossing("IN", frame_id=0) + tracker.mark_as_counted(1) + + updates = tracker.finalize_pending_confidences() + assert [(u.track_id, u.confidence) for u in updates] == [(1, "normal")] + assert state.confidence == "normal" + assert tracker.normal_confidence_count == 1 + + +def test_frame_zero_line1_event_starts_pending_confidence(): + tracker, _ = make_tracker_and_detector() + state = tracker.update(1, (50, 20), frame_id=0) + state.record_line1_crossing("IN", frame_id=0) + + assert tracker.should_count_event(state) is True + assert tracker.mark_as_counted(1) == "IN" + assert state.confidence == "pending" From 50a7552169ec769452ccab76988d66934d6d0018 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+YushinOgasawara@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:17:45 +0900 Subject: [PATCH 42/57] =?UTF-8?q?chore:=20margin=5Fpx=E3=81=AE=E6=97=A2?= =?UTF-8?q?=E5=AE=9A=E5=80=A4=E3=82=923c=E8=A8=88=E6=B8=AC=E7=B5=90?= =?UTF-8?q?=E6=9E=9C=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/.env.template | 2 +- raspi/line_detection/README.md | 2 +- raspi/line_detection/detection/config.py | 2 +- raspi/line_detection/detection/line_crossing.py | 2 +- raspi/line_detection/line_setup/setup_lines.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/raspi/line_detection/.env.template b/raspi/line_detection/.env.template index d2a47f31..da081f02 100644 --- a/raspi/line_detection/.env.template +++ b/raspi/line_detection/.env.template @@ -32,7 +32,7 @@ PARKING_REF_X=550 PARKING_REF_Y=100 # 検知パラメータ -MARGIN_PX=0.0 # 判定保留帯の半幅(px)。3cで正式決定(暫定値) +MARGIN_PX=5.0 # 判定保留帯の半幅(px)。3cスイープ採用値 ENDPOINT_MARGIN_PX=0.0 # 有限線分判定の端点許容量(px)。Phase 3の既定(変更しない) MAX_FRAME_GAP=90 # 3秒@30fps CLEANUP_THRESHOLD=150 # 5秒@30fps diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md index 3fc9db0c..7d26f1eb 100644 --- a/raspi/line_detection/README.md +++ b/raspi/line_detection/README.md @@ -282,7 +282,7 @@ CONFIDENCE_THRESHOLD=0.3 # 検知信頼度閾値(0.0-1.0) ### 検知パラメータ ```bash -MARGIN_PX=0.0 # 判定保留帯の半幅(px)。3cで正式決定 +MARGIN_PX=5.0 # 判定保留帯の半幅(px)。3cスイープ採用値 ENDPOINT_MARGIN_PX=0.0 # 有限線分判定の端点許容量(px) MAX_FRAME_GAP=90 # Line1とLine2の最大フレーム差(3秒@30fps) CLEANUP_THRESHOLD=150 # 古い追跡をクリーンアップ(5秒@30fps) diff --git a/raspi/line_detection/detection/config.py b/raspi/line_detection/detection/config.py index aa624c95..6007a4cb 100644 --- a/raspi/line_detection/detection/config.py +++ b/raspi/line_detection/detection/config.py @@ -125,7 +125,7 @@ def from_env(cls, env_path: str = None): " 数値をそのまま引き継ぐことはできません(ラインごとに換算係数が異なるため)。\n" " .env の MARGIN 行を削除し、MARGIN_PX と ENDPOINT_MARGIN_PX を追加してください。" ) - margin_px = float(os.getenv("MARGIN_PX", "0.0")) + margin_px = float(os.getenv("MARGIN_PX", "5.0")) endpoint_margin_px = float(os.getenv("ENDPOINT_MARGIN_PX", "0.0")) max_frame_gap = int(os.getenv("MAX_FRAME_GAP", "90")) cleanup_threshold = int(os.getenv("CLEANUP_THRESHOLD", "150")) diff --git a/raspi/line_detection/detection/line_crossing.py b/raspi/line_detection/detection/line_crossing.py index 9110c46a..f29a6a21 100644 --- a/raspi/line_detection/detection/line_crossing.py +++ b/raspi/line_detection/detection/line_crossing.py @@ -157,7 +157,7 @@ def __init__(self, line1: Line, line2: Line, parking_ref_point: Tuple[float, float], - margin_px: float = 1000.0, + margin_px: float = 5.0, endpoint_margin_px: float = 0.0): """ Args: diff --git a/raspi/line_detection/line_setup/setup_lines.py b/raspi/line_detection/line_setup/setup_lines.py index 002c649e..a732d8ce 100755 --- a/raspi/line_detection/line_setup/setup_lines.py +++ b/raspi/line_detection/line_setup/setup_lines.py @@ -257,7 +257,7 @@ def save_to_env(self): PARKING_REF_Y={self.points[4][1]} # 検知パラメータ -MARGIN_PX=0.0 +MARGIN_PX=5.0 ENDPOINT_MARGIN_PX=0.0 MAX_FRAME_GAP=90 # 3秒@30fps CLEANUP_THRESHOLD=150 # 5秒@30fps From 68a8900877177aaeeb794169d2fb4811f4ce2137 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:46:07 +0900 Subject: [PATCH 43/57] =?UTF-8?q?fix:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E3=83=95=E3=83=AC=E3=83=BC=E3=83=A0=E4=B8=A1?= =?UTF-8?q?=E7=B7=9A=E4=BA=A4=E5=B7=AE=E3=81=AE=E8=A8=98=E9=8C=B2=E9=A0=86?= =?UTF-8?q?=E3=82=92=E4=BA=A4=E7=82=B9=E4=BD=8D=E7=BD=AE=E3=81=A7=E6=B1=BA?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3f完了後の監査(decision-logic-audit.html)で発見したGate 1対象バグ のうち、2ラインの同一フレーム両線交差を修正。 main.pyの毎フレームループはLine1→Line2の順に固定でupdate_*_crossing()を 呼び、成立した順にpassed_orderへ記録していた。1フレームの移動が両線を またぐ場合、物理的な通過順(OUTならLine2→Line1)ではなく呼出順が記録される ため、VehicleState.resolve_confidence()の期待順序と食い違い、confidence が誤ってnormalに確定していた。 修正: line_crossing.pyにCrossingGeometry(t/u/point)とCrossingResult (direction/point)を追加し、_update_crossing()の戻り値をOptional[str]から Optional[CrossingResult]へ変更(交点座標を保持できるように)。main.pyでは Line1/Line2両方の判定を先に行い、同一フレームで両方成立した場合は現在点 からの交点距離が大きい方(=時間的に先に通過した方)を先に記録する。 last_stable_pointがLine1/Line2で異なっていても交点距離で正しく順序付け できることをテストで確認済み。 segment_crossing_param()は既存の単体テストを壊さないよう変更していない。 test_tracker.pyのprocess_frame()ヘルパーも同じ順序ロジックへ更新。 回帰テスト4件追加。修正前は AssertionError: assert ['line1', 'line2'] == ['line2', 'line1'] で失敗することを確認済み。uv run pytest raspi/line_detection -q: 60 passed(既存56+新規4)。 Co-Authored-By: Claude Sonnet 5 --- .../line_detection/detection/line_crossing.py | 76 ++++++++++++++----- raspi/line_detection/main.py | 31 ++++++-- .../tests/test_line_crossing.py | 21 ++++- raspi/line_detection/tests/test_tracker.py | 65 ++++++++++++++-- 4 files changed, 158 insertions(+), 35 deletions(-) diff --git a/raspi/line_detection/detection/line_crossing.py b/raspi/line_detection/detection/line_crossing.py index f29a6a21..03231aba 100644 --- a/raspi/line_detection/detection/line_crossing.py +++ b/raspi/line_detection/detection/line_crossing.py @@ -128,6 +128,50 @@ def segment_crossing_param(p_from: Tuple[float, float], return (ap_x * ab_x + ap_y * ab_y) / length_sq +@dataclass +class CrossingGeometry: + """交差点のライン上射影、移動線分上の位置、交差点座標""" + t: float + u: float + point: Tuple[float, float] + + +def segment_crossing_geometry( + p_from: Tuple[float, float], + p_to: Tuple[float, float], + line: Line) -> Optional[CrossingGeometry]: + """p_from→p_to と line の交差に関する幾何情報を返す。""" + d_from = signed_distance(p_from, line) + d_to = signed_distance(p_to, line) + + if d_from * d_to >= 0: + return None + + denom = d_from - d_to + if denom == 0.0: + return None + + u = d_from / denom + cross_x = p_from[0] + u * (p_to[0] - p_from[0]) + cross_y = p_from[1] + u * (p_to[1] - p_from[1]) + + ab_x = line.end[0] - line.start[0] + ab_y = line.end[1] - line.start[1] + ap_x = cross_x - line.start[0] + ap_y = cross_y - line.start[1] + length_sq = ab_x * ab_x + ab_y * ab_y + t = (ap_x * ab_x + ap_y * ab_y) / length_sq + + return CrossingGeometry(t=t, u=u, point=(cross_x, cross_y)) + + +@dataclass +class CrossingResult: + """成立したライン交差の方向と交差点""" + direction: str # "IN" or "OUT" + point: Tuple[float, float] + + @dataclass class LineTransitionState: """track 1本 × ライン1本ぶんのヒステリシス状態""" @@ -189,7 +233,7 @@ def __init__(self, def update_line1_crossing(self, transition_state: LineTransitionState, - curr_point: Tuple[float, float]) -> Optional[str]: + curr_point: Tuple[float, float]) -> Optional[CrossingResult]: """ Line1(入口側)の交差を判定し、ヒステリシス状態を更新する @@ -198,10 +242,7 @@ def update_line1_crossing(self, curr_point: 現フレームの車両位置 Returns: - Optional[str]: - "IN": 入庫方向に交差 - "OUT": 出庫方向に交差 - None: 交差なし + Optional[CrossingResult]: 成立した交差の方向と交差点、またはNone """ return self._update_crossing( transition_state, @@ -213,7 +254,7 @@ def update_line1_crossing(self, def update_line2_crossing(self, transition_state: LineTransitionState, - curr_point: Tuple[float, float]) -> Optional[str]: + curr_point: Tuple[float, float]) -> Optional[CrossingResult]: """ Line2(駐車場側)の交差を判定し、ヒステリシス状態を更新する @@ -222,10 +263,7 @@ def update_line2_crossing(self, curr_point: 現フレームの車両位置 Returns: - Optional[str]: - "IN": 入庫方向に交差 - "OUT": 出庫方向に交差 - None: 交差なし + Optional[CrossingResult]: 成立した交差の方向と交差点、またはNone """ return self._update_crossing( transition_state, @@ -240,7 +278,7 @@ def _update_crossing(self, curr_point: Tuple[float, float], line: Line, parking_side: float, - line_len: float) -> Optional[str]: + line_len: float) -> Optional[CrossingResult]: """ ヒステリシス方式によるライン交差判定(内部実装) @@ -252,10 +290,7 @@ def _update_crossing(self, line_len: lineの長さ(px) Returns: - Optional[str]: - "IN": 入庫方向に交差 - "OUT": 出庫方向に交差 - None: 交差なし + Optional[CrossingResult]: 成立した交差の方向と交差点、またはNone """ side = classify_side(curr_point, line, self.margin_px) @@ -276,17 +311,20 @@ def _update_crossing(self, return None # side == -stable_side: 反対の安定側へ抜けた - t = segment_crossing_param( + geometry = segment_crossing_geometry( transition_state.last_stable_point, curr_point, line ) event = None - if t is not None: + if geometry is not None: e = (self.endpoint_margin_px / line_len) if line_len > 0 else 0.0 - if -e <= t <= 1.0 + e: + if -e <= geometry.t <= 1.0 + e: # 駐車場基準点でIN/OUT判定 # sideとparking_sideの符号が同じ = 駐車場側に移動 = IN # sideとparking_sideの符号が異なる = 道路側に移動 = OUT - event = "IN" if side * parking_side > 0 else "OUT" + event = CrossingResult( + direction="IN" if side * parking_side > 0 else "OUT", + point=geometry.point, + ) # ブックキーピングは判定の成否と無関係に更新する。 # stable_sideは「curr_pointが現在どちらの安定側にいるか」という diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index 767a20c7..4a03ac28 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -7,6 +7,7 @@ import cv2 import argparse +import math import os import platform import sys @@ -346,16 +347,34 @@ def process_video( vehicle_point = get_vehicle_point(bbox) state = tracker.update(track_id, vehicle_point, frame_id) - line1_dir = detector.update_line1_crossing( + line1_result = detector.update_line1_crossing( state.line1_transition, state.curr_point ) - line2_dir = detector.update_line2_crossing( + line2_result = detector.update_line2_crossing( state.line2_transition, state.curr_point ) - if line1_dir and not state.counted: - state.record_line1_crossing(line1_dir, frame_id) - if line2_dir: - state.record_line2_crossing(line2_dir, frame_id) + crossings = [] + if line1_result is not None: + crossings.append(("line1", line1_result)) + if line2_result is not None: + crossings.append(("line2", line2_result)) + if len(crossings) == 2: + crossings.sort( + key=lambda item: math.dist( + state.curr_point, item[1].point + ), + reverse=True, + ) + for line_name, result in crossings: + if line_name == "line1": + if not state.counted: + state.record_line1_crossing( + result.direction, frame_id + ) + else: + state.record_line2_crossing( + result.direction, frame_id + ) if tracker.should_count_event(state): event_type = tracker.mark_as_counted(track_id) pending_events.append({ diff --git a/raspi/line_detection/tests/test_line_crossing.py b/raspi/line_detection/tests/test_line_crossing.py index ff800265..13a502fb 100644 --- a/raspi/line_detection/tests/test_line_crossing.py +++ b/raspi/line_detection/tests/test_line_crossing.py @@ -13,6 +13,7 @@ LineTransitionState, classify_side, line_length, + segment_crossing_geometry, segment_crossing_param, side_of_line, signed_distance, @@ -92,6 +93,15 @@ def test_segment_crossing_param_on_boundary_same_side_returns_none(): assert segment_crossing_param((40, 0), (60, 10), LINE) is None +def test_segment_crossing_geometry_returns_line_and_movement_parameters(): + geometry = segment_crossing_geometry((40, -20), (40, 30), LINE) + + assert geometry is not None + assert geometry.t == pytest.approx(0.40) + assert geometry.u == pytest.approx(0.40) + assert geometry.point == pytest.approx((40.0, 0.0)) + + def test_endpoint_margin_widens_valid_range(): # t=1.50 のケースを、endpoint_margin_pxを介した許容範囲で判定する t = segment_crossing_param((150, -20), (150, 30), LINE) @@ -142,7 +152,9 @@ def test_hysteresis_slow_crossing_detected_exactly_once(): results = [detector.update_line1_crossing(state, p) for p in points] - assert results == [None, None, None, None, "IN", None] + assert [result.direction if result else None for result in results] == [ + None, None, None, None, "IN", None + ] def test_hysteresis_large_single_frame_jump_still_detected(): @@ -150,7 +162,8 @@ def test_hysteresis_large_single_frame_jump_still_detected(): state = LineTransitionState() assert detector.update_line1_crossing(state, (50, -12)) is None - assert detector.update_line1_crossing(state, (50, 18)) == "IN" + result = detector.update_line1_crossing(state, (50, 18)) + assert result.direction == "IN" def test_hysteresis_extension_flip_reports_no_event_but_updates_stable_side(): @@ -176,7 +189,7 @@ def test_hysteresis_extension_flip_then_real_crossing_detected(): result = detector.update_line1_crossing(state, (50, -20)) - assert result == "OUT" + assert result.direction == "OUT" def test_hysteresis_never_initializes_from_dead_zone_point(): @@ -208,4 +221,4 @@ def test_hysteresis_endpoint_margin_widened_accepts_near_extension_crossing(): detector.update_line1_crossing(state, (-30, -20)) result = detector.update_line1_crossing(state, (-30, 30)) - assert result == "IN" + assert result.direction == "IN" diff --git a/raspi/line_detection/tests/test_tracker.py b/raspi/line_detection/tests/test_tracker.py index d6a0443a..e3b860b1 100644 --- a/raspi/line_detection/tests/test_tracker.py +++ b/raspi/line_detection/tests/test_tracker.py @@ -1,4 +1,5 @@ import sys +import math from pathlib import Path @@ -34,12 +35,28 @@ def process_frame(tracker, detector, track_id, point, frame_id): """main.py の per-frame ループ本体(cleanup呼び出しを除く)を1track分再現する。""" state = tracker.update(track_id, point, frame_id) - line1_dir = detector.update_line1_crossing(state.line1_transition, state.curr_point) - line2_dir = detector.update_line2_crossing(state.line2_transition, state.curr_point) - if line1_dir and not state.counted: - state.record_line1_crossing(line1_dir, frame_id) - if line2_dir: - state.record_line2_crossing(line2_dir, frame_id) + line1_result = detector.update_line1_crossing( + state.line1_transition, state.curr_point + ) + line2_result = detector.update_line2_crossing( + state.line2_transition, state.curr_point + ) + crossings = [] + if line1_result is not None: + crossings.append(("line1", line1_result)) + if line2_result is not None: + crossings.append(("line2", line2_result)) + if len(crossings) == 2: + crossings.sort( + key=lambda item: math.dist(state.curr_point, item[1].point), + reverse=True, + ) + for line_name, result in crossings: + if line_name == "line1": + if not state.counted: + state.record_line1_crossing(result.direction, frame_id) + else: + state.record_line2_crossing(result.direction, frame_id) if tracker.should_count_event(state): tracker.mark_as_counted(track_id) tracker.resolve_pending_confidences(frame_id) @@ -205,6 +222,42 @@ def test_out_line2_then_line1_resolves_high_on_line1_frame(): assert tracker.high_confidence_count == 1 +def test_out_single_frame_double_crossing_resolves_high_in_physical_order(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + process_frame(tracker, detector, 1, (50, 61), frame_id=0) + state = process_frame(tracker, detector, 1, (50, -11), frame_id=1) + + assert state.passed_order == ["line2", "line1"] + assert state.confidence == "high" + + +def test_in_single_frame_double_crossing_resolves_high_in_physical_order(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + process_frame(tracker, detector, 1, (50, -11), frame_id=0) + state = process_frame(tracker, detector, 1, (50, 61), frame_id=1) + + assert state.passed_order == ["line1", "line2"] + assert state.confidence == "high" + + +def test_same_frame_order_uses_crossing_points_when_start_points_differ(): + tracker, detector = make_tracker_and_detector(margin_px=5) + + process_frame(tracker, detector, 1, (50, 61), frame_id=0) + process_frame(tracker, detector, 1, (50, 58), frame_id=1) + state = process_frame(tracker, detector, 1, (50, 54), frame_id=2) + + assert state.line1_transition.last_stable_point == (50, 54) + assert state.line2_transition.last_stable_point == (50, 58) + + state = process_frame(tracker, detector, 1, (50, -11), frame_id=3) + + assert state.passed_order == ["line2", "line1"] + assert state.confidence == "high" + + def test_pending_in_resolves_normal_after_gap_exceeds_boundary(): tracker, _ = make_tracker_and_detector(max_frame_gap=3) state = tracker.update(1, (50, 10), frame_id=0) From 98ab5dbd59321d7fb1401bb3b8e163355ece02c5 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:18:17 +0900 Subject: [PATCH 44/57] =?UTF-8?q?fix:=20=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E8=AD=98=E5=88=A5=E5=AD=90=E3=82=92track=20ID?= =?UTF-8?q?=E3=81=8B=E3=82=89event=20ID=E3=81=B8=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3f完了後の監査(decision-logic-audit.html)で発見したGate 1対象バグ のうち、EventLoggerのtrack ID再利用時の衝突を修正。 EventLogger.update_confidence()はtrack_idでself.eventsを検索していたが、 track_idはVehicleTracker.cleanup_stale_tracks()でtrackが削除された後に 再利用されるYOLOのIDであり、一意ではない。確定済みイベント(旧track)と pendingイベント(同IDを再利用した新track)が共存すると、len(matched)>1で ValueErrorが送出され実行時に落ちる。 修正: Event/EventLoggerにevent_id(uuid4)を導入し、record_event()が 発行してeventの識別に使う。update_confidence()の検索キーをtrack_idから event_idへ変更。VehicleState.pending_event_id / ConfidenceUpdate.event_id を追加してtracker側からevent_idを橋渡しする。events_*.csv/jsonには event_id列を末尾に追加(condition_keyには含めない)。 main.pyの呼び出し順序も修正: resolve_pending_confidences()をcounting_timer からoutput_timerへ移し、record_event()でevent_idを確定させた後に呼ぶよう 変更した。IN方向でLine1/Line2を同一フレームで通過した場合、confidenceが 生成された同じフレームでhighに確定することが既存の挙動としてあり (2ライン同一フレーム両線交差の修正後はOUTも同様)、その場合に旧実装の 呼び出し順ではevent_idが未確定のままupdate_confidence()が呼ばれ RuntimeErrorで落ちる経路があったため。 回帰テスト: track_id再利用時の衝突(修正前はValueError)、同一フレームで confidenceが即確定するケース(修正前はRuntimeError)を追加、いずれも 修正前は失敗することを確認済み。uv run pytest raspi/line_detection -q: 63 passed(既存60+新規3)。 Co-Authored-By: Claude Sonnet 5 --- raspi/line_detection/detection/tracker.py | 4 + raspi/line_detection/main.py | 16 ++- .../result_output/event_logger.py | 26 +++-- .../tests/test_confidence_event_wiring.py | 104 ++++++++++++++++++ .../line_detection/tests/test_event_logger.py | 33 +++++- 5 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 raspi/line_detection/tests/test_confidence_event_wiring.py diff --git a/raspi/line_detection/detection/tracker.py b/raspi/line_detection/detection/tracker.py index b55ae5f1..698fddbf 100644 --- a/raspi/line_detection/detection/tracker.py +++ b/raspi/line_detection/detection/tracker.py @@ -19,6 +19,7 @@ class ConfidenceUpdate: """イベントログへ反映する確定済みconfidence。""" track_id: int + event_id: Optional[str] confidence: FinalConfidence line2_crossed: bool @@ -49,6 +50,7 @@ class VehicleState: passed_order: List[str] = field(default_factory=list) # ["line1", "line2"] など counted: bool = False confidence: Optional[Confidence] = None + pending_event_id: Optional[str] = None # 更新追跡 last_update_frame: int = 0 @@ -257,6 +259,7 @@ def resolve_pending_confidences( self._record_confidence_resolution(resolved) updates.append(ConfidenceUpdate( track_id=state.track_id, + event_id=state.pending_event_id, confidence=resolved, line2_crossed=state.line2_direction is not None, )) @@ -273,6 +276,7 @@ def finalize_pending_confidences(self) -> List[ConfidenceUpdate]: self._record_confidence_resolution("normal") updates.append(ConfidenceUpdate( track_id=state.track_id, + event_id=state.pending_event_id, confidence="normal", line2_crossed=state.line2_direction is not None, )) diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index 4a03ac28..512b2659 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -385,23 +385,27 @@ def process_video( "confidence": state.confidence, "line2_crossed": state.line2_direction is not None, }) - confidence_updates = tracker.resolve_pending_confidences(frame_id) tracker.cleanup_stale_tracks(frame_id) core_ms = inference_timer.elapsed_ms + counting_timer.elapsed_ms quit_requested = False with elapsed_timer() as output_timer: for event in pending_events: - event_logger.record_event(**event) + event_id = event_logger.record_event(**event) + state = tracker.get_state(event["track_id"]) + if state is not None: + state.pending_event_id = event_id + # event_idを割り当ててからconfidenceをイベントへ反映する必要がある。 + confidence_updates = tracker.resolve_pending_confidences(frame_id) for update in confidence_updates: if not event_logger.update_confidence( - update.track_id, + update.event_id, update.confidence, line2_crossed=update.line2_crossed, ): raise RuntimeError( "confidence更新対象のイベントが見つかりません: " - f"track_id={update.track_id}" + f"event_id={update.event_id}" ) for event in pending_events: if event["track_id"] == update.track_id: @@ -479,13 +483,13 @@ def process_video( final_confidence_updates = tracker.finalize_pending_confidences() for update in final_confidence_updates: if not event_logger.update_confidence( - update.track_id, + update.event_id, update.confidence, line2_crossed=update.line2_crossed, ): raise RuntimeError( "終了時confidence更新対象のイベントが見つかりません: " - f"track_id={update.track_id}" + f"event_id={update.event_id}" ) orphan_pending = event_logger.finalize_pending_confidences() if orphan_pending: diff --git a/raspi/line_detection/result_output/event_logger.py b/raspi/line_detection/result_output/event_logger.py index 4cf416c9..9df70535 100644 --- a/raspi/line_detection/result_output/event_logger.py +++ b/raspi/line_detection/result_output/event_logger.py @@ -6,6 +6,7 @@ import json import csv import os +import uuid from datetime import datetime from typing import List, Dict, Any, Optional @@ -14,6 +15,7 @@ class Event: """イベントデータクラス""" def __init__(self, + event_id: str, track_id: int, event_type: str, frame_id: int, @@ -22,6 +24,7 @@ def __init__(self, line2_crossed: bool): """ Args: + event_id: イベントを一意に識別するID track_id: トラッキングID event_type: イベントタイプ("IN" or "OUT") frame_id: フレーム番号 @@ -29,6 +32,7 @@ def __init__(self, confidence: 信頼度("high" or "normal") line2_crossed: Line2を交差したか """ + self.event_id = event_id self.track_id = track_id self.event_type = event_type self.frame_id = frame_id @@ -44,7 +48,8 @@ def to_dict(self) -> Dict[str, Any]: "frame_id": self.frame_id, "timestamp_sec": round(self.timestamp_sec, 2), "confidence": self.confidence, - "line2_crossed": self.line2_crossed + "line2_crossed": self.line2_crossed, + "event_id": self.event_id, } @@ -68,7 +73,7 @@ def record_event(self, frame_id: int, fps: float, confidence: str, - line2_crossed: bool): + line2_crossed: bool) -> str: """ イベントを記録 @@ -82,7 +87,9 @@ def record_event(self, """ timestamp_sec = frame_id / fps if fps > 0 else 0.0 + event_id = uuid.uuid4().hex event = Event( + event_id=event_id, track_id=track_id, event_type=event_type, frame_id=frame_id, @@ -92,19 +99,18 @@ def record_event(self, ) self.events.append(event) + return event_id def update_confidence( self, - track_id: int, + event_id: str, confidence: str, line2_crossed: Optional[bool] = None, ) -> bool: - """track_idで既存イベントを検索し、後追いの確定結果を反映する。""" - matched = [event for event in self.events if event.track_id == track_id] + """event_idで既存イベントを検索し、後追いの確定結果を反映する。""" + matched = [event for event in self.events if event.event_id == event_id] if not matched: return False - if len(matched) > 1: - raise ValueError(f"同一track_idのイベントが重複しています: {track_id}") event = matched[0] event.confidence = confidence @@ -287,7 +293,8 @@ def save_csv(self, output_dir: str) -> str: "frame_id", "timestamp_sec", "confidence", - "line2_crossed" + "line2_crossed", + "event_id", ]) else: # イベントがある場合 @@ -297,7 +304,8 @@ def save_csv(self, output_dir: str) -> str: "frame_id", "timestamp_sec", "confidence", - "line2_crossed" + "line2_crossed", + "event_id", ] writer = csv.DictWriter(f, fieldnames=fieldnames) diff --git a/raspi/line_detection/tests/test_confidence_event_wiring.py b/raspi/line_detection/tests/test_confidence_event_wiring.py new file mode 100644 index 00000000..85f01635 --- /dev/null +++ b/raspi/line_detection/tests/test_confidence_event_wiring.py @@ -0,0 +1,104 @@ +import math +import sys +from pathlib import Path + + +LINE_ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(LINE_ROOT)) + +from detection.config import Line +from detection.line_crossing import LineCrossingDetector +from detection.tracker import VehicleTracker +from result_output.event_logger import EventLogger + + +LINE1 = Line(start=(0, 0), end=(100, 0)) +LINE2 = Line(start=(0, 50), end=(100, 50)) +PARKING_REF_POINT = (50, 100) + + +def process_frame(tracker, detector, event_logger, track_id, point, frame_id): + """Corrected main.py per-frame ordering for one detection.""" + state = tracker.update(track_id, point, frame_id) + + line1_result = detector.update_line1_crossing( + state.line1_transition, state.curr_point + ) + line2_result = detector.update_line2_crossing( + state.line2_transition, state.curr_point + ) + crossings = [] + if line1_result is not None: + crossings.append(("line1", line1_result)) + if line2_result is not None: + crossings.append(("line2", line2_result)) + if len(crossings) == 2: + crossings.sort( + key=lambda item: math.dist(state.curr_point, item[1].point), + reverse=True, + ) + + for line_name, result in crossings: + if line_name == "line1": + if not state.counted: + state.record_line1_crossing(result.direction, frame_id) + else: + state.record_line2_crossing(result.direction, frame_id) + + pending_events = [] + if tracker.should_count_event(state): + event_type = tracker.mark_as_counted(track_id) + pending_events.append({ + "track_id": track_id, + "event_type": event_type, + "frame_id": frame_id, + "fps": 30.0, + "confidence": state.confidence, + "line2_crossed": state.line2_direction is not None, + }) + + tracker.cleanup_stale_tracks(frame_id) + + for event in pending_events: + event_id = event_logger.record_event(**event) + state = tracker.get_state(event["track_id"]) + if state is not None: + state.pending_event_id = event_id + + # The event must be recorded before its event_id can receive a confidence update. + confidence_updates = tracker.resolve_pending_confidences(frame_id) + for update in confidence_updates: + assert event_logger.update_confidence( + update.event_id, + update.confidence, + line2_crossed=update.line2_crossed, + ) is True + for event in pending_events: + if event["track_id"] == update.track_id: + event["confidence"] = update.confidence + event["line2_crossed"] = update.line2_crossed + + return state + + +def test_same_frame_double_crossing_wires_event_id_before_confidence_update(): + detector = LineCrossingDetector( + line1=LINE1, + line2=LINE2, + parking_ref_point=PARKING_REF_POINT, + margin_px=5.0, + endpoint_margin_px=0.0, + ) + tracker = VehicleTracker(max_frame_gap=90, cleanup_threshold=150) + event_logger = EventLogger(video_path="test.mp4") + + process_frame(tracker, detector, event_logger, 1, (50, -11), frame_id=0) + state = process_frame( + tracker, detector, event_logger, 1, (50, 61), frame_id=1 + ) + + assert state.passed_order == ["line1", "line2"] + assert state.confidence == "high" + assert state.pending_event_id == event_logger.events[0].event_id + assert event_logger.events[0].confidence == "high" + assert event_logger.events[0].line2_crossed is True diff --git a/raspi/line_detection/tests/test_event_logger.py b/raspi/line_detection/tests/test_event_logger.py index c13edb7c..20a87149 100644 --- a/raspi/line_detection/tests/test_event_logger.py +++ b/raspi/line_detection/tests/test_event_logger.py @@ -83,7 +83,7 @@ def test_save_json_omits_accuracy_block_when_absent(tmp_path): def test_update_confidence_updates_event_and_line2_crossing(): logger = EventLogger(video_path="test.mp4") - logger.record_event( + event_id = logger.record_event( track_id=7, event_type="IN", frame_id=10, @@ -92,14 +92,39 @@ def test_update_confidence_updates_event_and_line2_crossing(): line2_crossed=False, ) - assert logger.update_confidence(7, "high", line2_crossed=True) is True + assert logger.update_confidence(event_id, "high", line2_crossed=True) is True assert logger.events[0].confidence == "high" assert logger.events[0].line2_crossed is True -def test_update_confidence_returns_false_for_unknown_track(): +def test_update_confidence_returns_false_for_unknown_event(): logger = EventLogger(video_path="test.mp4") - assert logger.update_confidence(999, "normal") is False + assert logger.update_confidence("missing-event-id", "normal") is False + + +def test_record_event_generates_unique_event_ids(): + logger = EventLogger(video_path="test.mp4") + event_ids = { + logger.record_event(1, "IN", frame_id, 30.0, "pending", False) + for frame_id in range(3) + } + + assert len(event_ids) == 3 + + +def test_update_confidence_targets_reused_track_id_event_by_event_id(): + logger = EventLogger(video_path="test.mp4") + resolved_event_id = logger.record_event(7, "IN", 1, 30.0, "high", True) + pending_event_id = logger.record_event(7, "IN", 2, 30.0, "pending", False) + + assert logger.update_confidence( + pending_event_id, "normal", line2_crossed=True + ) is True + assert logger.events[0].event_id == resolved_event_id + assert logger.events[0].confidence == "high" + assert logger.events[1].event_id == pending_event_id + assert logger.events[1].confidence == "normal" + assert logger.events[1].line2_crossed is True def test_finalize_pending_confidences_and_validate_summary(): From b4b204596eb79c405a95a8916b48e2828ce6c670 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:20:18 +0900 Subject: [PATCH 45/57] =?UTF-8?q?chore:=20.vscode/settings.json=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index d7056680..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "eslint.workingDirectories": ["./view"], -} From 7d02fac313f2e3cafa24502267ddde6c44d20bb9 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:52:47 +0900 Subject: [PATCH 46/57] =?UTF-8?q?feat:=20GT=E3=82=B9=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=81=B8=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88=E5=8D=98?= =?UTF-8?q?=E4=BD=8D=E3=81=AEevents/tolerance=5Fsec=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 Gate3(GT拡張とイベント単位評価)対応。ROIブランチ (feat/mike/89-bbox-analysis-within-roi)へ移植・拡張した raspi/common/ground_truth.pyと同一内容をこのブランチへ適用する (両ブランチでbyte-identicalであることをdiffで確認済み)。 追加内容: - GtEvent(event_id, direction, t_sec)データクラス - GroundTruthへevents: tuple[GtEvent, ...] = ()、 tolerance_sec: float = DEFAULT_TOLERANCE_SEC(10.0)を追加 (新フィールドはデフォルト値ありのため、既存のGTファイル・呼び出し元は 無変更で動作する) - _load_gt_file()でevents配列・tolerance_secキーを検証つきでパース (event_id重複・direction不正・t_sec負値/非数値・tolerance_sec非正値を拒否) - events/tolerance_secがcomparison_keyの対象外であることのテストを追加 uv run pytest raspi/ -q: 137 passed(既存127 + 新規10)。 Co-Authored-By: Claude Sonnet 5 --- raspi/common/ground_truth.py | 105 ++++++++++++++++++++++-- raspi/common/tests/test_ground_truth.py | 91 +++++++++++++++++++- 2 files changed, 189 insertions(+), 7 deletions(-) diff --git a/raspi/common/ground_truth.py b/raspi/common/ground_truth.py index c5e884b4..dd91b2b8 100644 --- a/raspi/common/ground_truth.py +++ b/raspi/common/ground_truth.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import math from dataclasses import dataclass from pathlib import Path from typing import Any @@ -10,14 +11,28 @@ from common.frame_timing import sha256_file +@dataclass(frozen=True) +class GtEvent: + """GTの1物理イベント(入退場1件)。""" + + event_id: str + direction: str # "IN" or "OUT" + t_sec: float + + +DEFAULT_TOLERANCE_SEC = 10.0 # 初期既定値。2ラインとROIの確定タイミング差を吸収する目的の暫定値。 + + @dataclass(frozen=True) class GroundTruth: - """1動画分のGT。値が無い方向は ``None``(未確認)として扱う。""" + """1動画分のGT。値が無い方向は None(未確認)として扱う。""" path: str | None sha256: str | None gt_in: int | None gt_out: int | None + events: tuple["GtEvent", ...] = () + tolerance_sec: float = DEFAULT_TOLERANCE_SEC @property def is_available(self) -> bool: @@ -29,7 +44,7 @@ def absent(cls) -> "GroundTruth": def _coerce_count(value: Any, *, key: str, source: str) -> int | None: - """GT の1方向分の値を検証する。``null`` は未確認として ``None`` を返す。""" + """GT の1方向分の値を検証する。null は未確認として None を返す。""" if value is None: return None @@ -53,6 +68,71 @@ def _coerce_count(value: Any, *, key: str, source: str) -> int | None: return count +def _coerce_event_time(value: Any, *, key: str, source: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{source}: '{key}' は有限な数値である必要があります: {value!r}") + try: + number = float(value) + except (OverflowError, ValueError) as exc: + raise ValueError(f"{source}: '{key}' は有限な数値である必要があります: {value!r}") from exc + if not math.isfinite(number): + raise ValueError(f"{source}: '{key}' は有限な数値である必要があります: {value!r}") + if number < 0: + raise ValueError(f"{source}: '{key}' は0以上である必要があります: {value!r}") + return number + + +def _coerce_tolerance(value: Any, *, source: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError( + f"{source}: 'tolerance_sec' は有限な正の数値である必要があります: {value!r}" + ) + try: + number = float(value) + except (OverflowError, ValueError) as exc: + raise ValueError( + f"{source}: 'tolerance_sec' は有限な正の数値である必要があります: {value!r}" + ) from exc + if not math.isfinite(number) or number <= 0: + raise ValueError( + f"{source}: 'tolerance_sec' は有限な正の数値である必要があります: {value!r}" + ) + return number + + +def _parse_events(data: dict[str, Any], *, source: str) -> tuple[GtEvent, ...]: + if "events" not in data: + return () + raw_events = data["events"] + if not isinstance(raw_events, list): + raise ValueError(f"{source}: 'events' は配列である必要があります") + + events = [] + event_ids: set[str] = set() + for index, raw_event in enumerate(raw_events): + event_key = f"events[{index}]" + if not isinstance(raw_event, dict): + raise ValueError(f"{source}: {event_key} はオブジェクトである必要があります") + + event_id = raw_event.get("event_id") + if not isinstance(event_id, str) or not event_id.strip(): + raise ValueError(f"{source}: {event_key} の 'event_id' は空でない文字列である必要があります") + if event_id in event_ids: + raise ValueError(f"{source}: 'event_id' が重複しています: {event_id!r}") + event_ids.add(event_id) + + direction = raw_event.get("direction") + if direction not in ("IN", "OUT"): + raise ValueError( + f"{source}: {event_key} の 'direction' は 'IN' または 'OUT' である必要があります: " + f"{direction!r}" + ) + + t_sec = _coerce_event_time(raw_event.get("t_sec"), key=f"{event_key}.t_sec", source=source) + events.append(GtEvent(event_id=event_id, direction=direction, t_sec=t_sec)) + return tuple(events) + + def _load_gt_file(path: Path, *, video_source: str | int) -> GroundTruth: source = str(path) try: @@ -70,6 +150,12 @@ def _load_gt_file(path: Path, *, video_source: str | int) -> GroundTruth: gt_in = _coerce_count(data.get("in"), key="in", source=source) gt_out = _coerce_count(data.get("out"), key="out", source=source) + events = _parse_events(data, source=source) + tolerance_sec = ( + _coerce_tolerance(data["tolerance_sec"], source=source) + if "tolerance_sec" in data + else DEFAULT_TOLERANCE_SEC + ) video_field = data.get("video") if isinstance(video_field, str) and isinstance(video_source, str): @@ -79,11 +165,18 @@ def _load_gt_file(path: Path, *, video_source: str | int) -> GroundTruth: f"('{video_source}') のファイル名が一致しません" ) - return GroundTruth(path=source, sha256=sha256_file(source), gt_in=gt_in, gt_out=gt_out) + return GroundTruth( + path=source, + sha256=sha256_file(source), + gt_in=gt_in, + gt_out=gt_out, + events=events, + tolerance_sec=tolerance_sec, + ) def resolve_gt_path(video_source: str | int, explicit_path: str | None) -> tuple[Path | None, bool]: - """GTファイルのパスを決定する。返り値は ``(path, is_explicit)``。""" + """GTファイルのパスを決定する。返り値は (path, is_explicit)。""" if explicit_path: return Path(explicit_path), True @@ -96,8 +189,8 @@ def resolve_gt_path(video_source: str | int, explicit_path: str | None) -> tuple def load_ground_truth(video_source: str | int, explicit_path: str | None = None) -> GroundTruth: """GTを読み込む。 - 明示パス指定でファイルが無ければ ``FileNotFoundError``(操作者の意図を裏切らない)。 - 自動導出したパスが無い場合は警告のみで ``GroundTruth.absent()`` を返す。 + 明示パス指定でファイルが無ければ FileNotFoundError(操作者の意図を裏切らない)。 + 自動導出したパスが無い場合は警告のみで GroundTruth.absent() を返す。 """ path, is_explicit = resolve_gt_path(video_source, explicit_path) diff --git a/raspi/common/tests/test_ground_truth.py b/raspi/common/tests/test_ground_truth.py index a32fda18..00f217d0 100644 --- a/raspi/common/tests/test_ground_truth.py +++ b/raspi/common/tests/test_ground_truth.py @@ -4,6 +4,8 @@ from common.frame_timing import COMPARISON_CONFIG_KEYS from common.ground_truth import ( + DEFAULT_TOLERANCE_SEC, + GtEvent, GroundTruth, build_ground_truth_config, build_ground_truth_summary, @@ -148,7 +150,14 @@ def test_count_error_matches_roi_04_formula_when_both_directions_present(): def test_comparison_key_ignores_ground_truth_keys(): - for key in ("ground_truth_config", "ground_truth_sha256", "gt_in", "gt_out"): + for key in ( + "ground_truth_config", + "ground_truth_sha256", + "gt_in", + "gt_out", + "events", + "tolerance_sec", + ): assert key not in COMPARISON_CONFIG_KEYS @@ -167,3 +176,83 @@ def test_build_ground_truth_summary_includes_count_error(): summary = build_ground_truth_summary(20, 1, gt) assert summary["gt_in"] == 22 assert summary["count_error"] == 3 + + +def test_gt_without_events_and_tolerance_is_backward_compatible(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": 22, "out": 0}) + gt = load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + assert gt.events == () + assert gt.tolerance_sec == DEFAULT_TOLERANCE_SEC + + +def test_valid_events_and_tolerance_are_parsed(tmp_path): + path = write_gt( + tmp_path, + "gt.json", + { + "in": 1, + "out": 1, + "events": [ + {"event_id": "event-1", "direction": "IN", "t_sec": 12}, + {"event_id": "event-2", "direction": "OUT", "t_sec": 45.5}, + ], + "tolerance_sec": 7.5, + }, + ) + gt = load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + assert gt.events == ( + GtEvent(event_id="event-1", direction="IN", t_sec=12.0), + GtEvent(event_id="event-2", direction="OUT", t_sec=45.5), + ) + assert gt.tolerance_sec == 7.5 + + +def test_duplicate_event_id_is_rejected(tmp_path): + path = write_gt( + tmp_path, + "gt.json", + { + "in": 2, + "out": 0, + "events": [ + {"event_id": "same", "direction": "IN", "t_sec": 1}, + {"event_id": "same", "direction": "OUT", "t_sec": 2}, + ], + }, + ) + with pytest.raises(ValueError, match=str(path)): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_invalid_event_direction_is_rejected(tmp_path): + path = write_gt( + tmp_path, + "gt.json", + {"in": 1, "out": 0, "events": [{"event_id": "event-1", "direction": "SIDE", "t_sec": 1}]}, + ) + with pytest.raises(ValueError, match=str(path)): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +@pytest.mark.parametrize("t_sec", [-1, "not-a-number"]) +def test_invalid_event_time_is_rejected(tmp_path, t_sec): + path = write_gt( + tmp_path, + "gt.json", + {"in": 1, "out": 0, "events": [{"event_id": "event-1", "direction": "IN", "t_sec": t_sec}]}, + ) + with pytest.raises(ValueError, match=str(path)): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +def test_events_must_be_a_list(tmp_path): + path = write_gt(tmp_path, "gt.json", {"in": 1, "out": 0, "events": {}}) + with pytest.raises(ValueError, match=str(path)): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) + + +@pytest.mark.parametrize("tolerance_sec", [0, -1, "not-a-number"]) +def test_invalid_tolerance_is_rejected(tmp_path, tolerance_sec): + path = write_gt(tmp_path, "gt.json", {"in": 1, "out": 0, "tolerance_sec": tolerance_sec}) + with pytest.raises(ValueError, match=str(path)): + load_ground_truth(str(tmp_path / "clip.MOV"), str(path)) From 04099b05fc76d77938f133222250b702e3e7b519 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:57:34 +0900 Subject: [PATCH 47/57] =?UTF-8?q?feat:=20GT=E3=81=A8=E4=BA=88=E6=B8=AC?= =?UTF-8?q?=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88=E3=81=AE=E7=AA=81=E5=90=88?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=ABevent=5Fmatching.p?= =?UTF-8?q?y=E3=82=92=E6=96=B0=E8=A8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 Gate3(GT拡張とイベント単位評価)対応。 raspi/common/event_matching.pyを新設し、方向一致かつ時刻差が tolerance_sec以内のGT/予測イベントペアを、時刻差の小さい順に貪欲マッチング する。1つのGTイベントは1つの予測にしか対応しない(1対1)。方向(IN/OUT) ごとに独立してマッチングし、全体のtp/fp/fnはその合算。 precision/recall/f1のゼロ除算規約: - precision: tp+fp==0のときNone(予測が1件も無い場合は未定義) - recall: tp+fn==0のときNone(GTが1件も無い場合は未定義) - f1: precision/recallの片方でもNoneならtp+fp/tp+fnの非ゼロ側のみで 判断できないため0.0、両方Noneなら未定義としてNone、それ以外は調和平均 同時刻差の候補が複数ある場合はGT event_id→予測event_idの順で 決定的にタイブレークする(入力順序に依存しない)。 このモジュールは2ラインブランチ・ROIブランチへ同一内容で配置する (raspi/commonの共有規約に従う。次のコミットでROI側にも同一内容を配置)。 合成データでの単体テスト11件: 完全一致、tolerance境界、方向不一致、 過剰予測(FP)、予測不足(FN)、最近傍優先の貪欲マッチング、 タイブレークの決定性、空入力の3パターン、方向別の独立集計。 uv run pytest raspi/ -q: 148 passed(既存137 + 新規11)。 Co-Authored-By: Claude Sonnet 5 --- raspi/common/event_matching.py | 125 +++++++++++++++ raspi/common/tests/test_event_matching.py | 180 ++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 raspi/common/event_matching.py create mode 100644 raspi/common/tests/test_event_matching.py diff --git a/raspi/common/event_matching.py b/raspi/common/event_matching.py new file mode 100644 index 00000000..ecd27052 --- /dev/null +++ b/raspi/common/event_matching.py @@ -0,0 +1,125 @@ +"""GTと予測イベントの突合、および精度指標の算出。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + + +@dataclass(frozen=True) +class PredictedEvent: + """検出方式が出力した1件の予測イベント。""" + + event_id: str + direction: str # "IN" or "OUT" + t_sec: float + + +@dataclass(frozen=True) +class GtEvent: + """GTの1物理イベント。raspi.common.ground_truth.GtEvent と同型。""" + + event_id: str + direction: str + t_sec: float + + +@dataclass(frozen=True) +class MatchResult: + tp: int + fp: int + fn: int + tp_in: int + fp_in: int + fn_in: int + tp_out: int + fp_out: int + fn_out: int + precision: float | None + recall: float | None + f1: float | None + + +def _match_direction( + predicted: Sequence[PredictedEvent], + gt: Sequence[GtEvent], + direction: str, + tolerance_sec: float, +) -> tuple[int, int, int]: + predicted_in_direction = [ + (index, event) for index, event in enumerate(predicted) if event.direction == direction + ] + gt_in_direction = [ + (index, event) for index, event in enumerate(gt) if event.direction == direction + ] + + candidates = [] + for predicted_index, predicted_event in predicted_in_direction: + for gt_index, gt_event in gt_in_direction: + time_difference = abs(predicted_event.t_sec - gt_event.t_sec) + if time_difference <= tolerance_sec: + candidates.append( + ( + time_difference, + gt_event.event_id, + predicted_event.event_id, + predicted_index, + gt_index, + ) + ) + + candidates.sort() + consumed_predicted: set[int] = set() + consumed_gt: set[int] = set() + tp = 0 + + for _, _, _, predicted_index, gt_index in candidates: + if predicted_index in consumed_predicted or gt_index in consumed_gt: + continue + consumed_predicted.add(predicted_index) + consumed_gt.add(gt_index) + tp += 1 + + fp = len(predicted_in_direction) - tp + fn = len(gt_in_direction) - tp + return tp, fp, fn + + +def match_events( + predicted: Sequence[PredictedEvent], + gt: Sequence[GtEvent], + tolerance_sec: float, +) -> MatchResult: + """予測イベントを方向別にGTへ1対1で突合し、全体の指標を返す。""" + + tp_in, fp_in, fn_in = _match_direction(predicted, gt, "IN", tolerance_sec) + tp_out, fp_out, fn_out = _match_direction(predicted, gt, "OUT", tolerance_sec) + + tp = tp_in + tp_out + fp = fp_in + fp_out + fn = fn_in + fn_out + + precision = tp / (tp + fp) if tp + fp > 0 else None + recall = tp / (tp + fn) if tp + fn > 0 else None + + if precision is None and recall is None: + f1 = None + elif precision is None or recall is None or precision + recall == 0: + f1 = 0.0 + else: + f1 = 2 * precision * recall / (precision + recall) + + return MatchResult( + tp=tp, + fp=fp, + fn=fn, + tp_in=tp_in, + fp_in=fp_in, + fn_in=fn_in, + tp_out=tp_out, + fp_out=fp_out, + fn_out=fn_out, + precision=precision, + recall=recall, + f1=f1, + ) diff --git a/raspi/common/tests/test_event_matching.py b/raspi/common/tests/test_event_matching.py new file mode 100644 index 00000000..a2d907d9 --- /dev/null +++ b/raspi/common/tests/test_event_matching.py @@ -0,0 +1,180 @@ +from common.event_matching import GtEvent, PredictedEvent, match_events + + +def test_perfect_match_across_directions(): + predicted = [ + PredictedEvent("pred-in", "IN", 1.0), + PredictedEvent("pred-out", "OUT", 8.0), + ] + gt = [ + GtEvent("gt-in", "IN", 1.0), + GtEvent("gt-out", "OUT", 8.0), + ] + + result = match_events(predicted, gt, tolerance_sec=0.5) + + assert result.tp == 2 + assert result.fp == 0 + assert result.fn == 0 + assert result.precision == result.recall == result.f1 == 1.0 + + +def test_tolerance_boundary_is_inclusive(): + predicted = [ + PredictedEvent("at-boundary", "IN", 1.5), + PredictedEvent("outside", "IN", 3.51), + ] + gt = [ + GtEvent("boundary-gt", "IN", 1.0), + GtEvent("outside-gt", "IN", 3.0), + ] + + result = match_events(predicted, gt, tolerance_sec=0.5) + + assert result.tp == 1 + assert result.fp == 1 + assert result.fn == 1 + + +def test_direction_mismatch_never_matches(): + result = match_events( + [PredictedEvent("pred-in", "IN", 4.0)], + [GtEvent("gt-out", "OUT", 4.0)], + tolerance_sec=0.0, + ) + + assert result.tp == 0 + assert result.fp_in == 1 + assert result.fn_in == 0 + assert result.tp_out == 0 + assert result.fp_out == 0 + assert result.fn_out == 1 + + +def test_extra_predictions_are_false_positives(): + predicted = [ + PredictedEvent("pred-1", "IN", 1.0), + PredictedEvent("pred-2", "IN", 2.0), + ] + gt = [GtEvent("gt-1", "IN", 1.0)] + + result = match_events(predicted, gt, tolerance_sec=0.1) + + assert result.tp_in == 1 + assert result.fp_in == 1 + assert result.fn_in == 0 + assert result.precision < 1.0 + assert result.recall == 1.0 + + +def test_missing_predictions_are_false_negatives(): + predicted = [PredictedEvent("pred-1", "OUT", 1.0)] + gt = [ + GtEvent("gt-1", "OUT", 1.0), + GtEvent("gt-2", "OUT", 2.0), + ] + + result = match_events(predicted, gt, tolerance_sec=0.1) + + assert result.tp_out == 1 + assert result.fp_out == 0 + assert result.fn_out == 1 + assert result.recall < 1.0 + + +def test_greedy_matching_uses_nearest_time_difference_first(): + predicted = [ + PredictedEvent("pred-near-start", "IN", 0.5), + PredictedEvent("pred-near-end", "IN", 9.5), + ] + gt = [ + GtEvent("gt-start", "IN", 0.0), + GtEvent("gt-end", "IN", 10.0), + ] + + result = match_events(predicted, gt, tolerance_sec=20.0) + + assert result.tp == 2 + assert result.fp == 0 + assert result.fn == 0 + + +def test_tie_breaking_is_deterministic_across_input_orderings(): + predicted = [ + PredictedEvent("pred-b", "IN", 1.0), + PredictedEvent("pred-a", "IN", -1.0), + ] + gt = [ + GtEvent("gt-b", "IN", 0.0), + GtEvent("gt-a", "IN", 0.0), + ] + + result_in_original_order = match_events(predicted, gt, tolerance_sec=1.0) + result_in_reversed_order = match_events( + list(reversed(predicted)), list(reversed(gt)), tolerance_sec=1.0 + ) + + assert result_in_original_order == result_in_reversed_order + assert result_in_original_order.tp == 2 + + +def test_empty_predictions_have_undefined_precision_but_zero_f1(): + result = match_events( + [], + [GtEvent("gt-in", "IN", 1.0), GtEvent("gt-out", "OUT", 2.0)], + tolerance_sec=0.5, + ) + + assert result.tp == 0 + assert result.fp == 0 + assert result.fn == 2 + assert result.precision is None + assert result.recall == 0.0 + assert result.f1 == 0.0 + + +def test_empty_gt_have_undefined_recall_but_zero_f1(): + result = match_events( + [PredictedEvent("pred-in", "IN", 1.0), PredictedEvent("pred-out", "OUT", 2.0)], + [], + tolerance_sec=0.5, + ) + + assert result.tp == 0 + assert result.fp == 2 + assert result.fn == 0 + assert result.precision == 0.0 + assert result.recall is None + assert result.f1 == 0.0 + + +def test_both_inputs_empty_leave_all_metrics_undefined(): + result = match_events([], [], tolerance_sec=0.5) + + assert result.tp == result.fp == result.fn == 0 + assert result.precision is None + assert result.recall is None + assert result.f1 is None + + +def test_directions_are_matched_and_counted_independently(): + predicted = [ + PredictedEvent("pred-in", "IN", 1.0), + PredictedEvent("pred-out", "OUT", 10.0), + ] + gt = [ + GtEvent("gt-in", "IN", 1.0), + GtEvent("gt-out", "OUT", 20.0), + ] + + result = match_events(predicted, gt, tolerance_sec=0.5) + + assert result.tp_in == 1 + assert result.fp_in == 0 + assert result.fn_in == 0 + assert result.tp_out == 0 + assert result.fp_out == 1 + assert result.fn_out == 1 + assert result.tp == 1 + assert result.fp == 1 + assert result.fn == 1 From b37a1b1f1ab0d2d8c32a15c7ee4bdc05b882d514 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:59:27 +0900 Subject: [PATCH 48/57] =?UTF-8?q?feat:=20raspi/eval/=E3=82=922=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=96=E3=83=A9=E3=83=B3=E3=83=81=E3=81=B8?= =?UTF-8?q?=E7=A7=BB=E6=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 Gate3(GT拡張とイベント単位評価)対応。「実行と評価の分離」 (計測スクリプトは精度系summaryキーをNoneで確保するだけにとどめ、GTが 揃った後に別スクリプトでW&B runのsummaryへ後追い書き込みする)という 設計方針は、ROIブランチ限定ではなく両方式に共通して当てはまる。 ROIブランチ(feat/mike/89-bbox-analysis-within-roi)のraspi/eval/を そのまま移植した(動作変更なし、byte-identical)。 これにより2ライン側でも、G3-2の今後の作業(event_matching.pyの出力を update_accuracy_from_sam3.pyの入力形式へ変換するスクリプト)で 同じ後追い書き込みの仕組みを使える。 uv run pytest raspi/ -q: 152 passed(既存148 + 新規4)。 Co-Authored-By: Claude Sonnet 5 --- .../tests/test_update_accuracy_from_sam3.py | 71 ++++++++ raspi/eval/update_accuracy_from_sam3.py | 168 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 raspi/eval/tests/test_update_accuracy_from_sam3.py create mode 100644 raspi/eval/update_accuracy_from_sam3.py diff --git a/raspi/eval/tests/test_update_accuracy_from_sam3.py b/raspi/eval/tests/test_update_accuracy_from_sam3.py new file mode 100644 index 00000000..71a75bbb --- /dev/null +++ b/raspi/eval/tests/test_update_accuracy_from_sam3.py @@ -0,0 +1,71 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[2])) # raspi/ + +from eval.update_accuracy_from_sam3 import AmbiguousRunError, find_run + + +class FakeApi: + def __init__(self, runs=None): + self.found_runs = runs or [] + self.run_paths = [] + self.filters = [] + + def run(self, path): + self.run_paths.append(path) + return SimpleNamespace(id=path.rsplit("/", 1)[-1], name="direct") + + def runs(self, project, filters): + self.filters.append((project, filters)) + return self.found_runs + + +def test_find_run_prefers_wandb_run_id(): + api = FakeApi() + run = find_run( + api, + { + "wandb_run_id": "wandb-1", + "execution_id": "execution-1", + "condition_key": "condition-1", + }, + ) + assert run.id == "wandb-1" + assert api.run_paths == ["tracking-parking/wandb-1"] + assert api.filters == [] + + +def test_find_run_uses_execution_id_before_condition_key(): + api = FakeApi([SimpleNamespace(id="wandb-1", name="run")]) + run = find_run( + api, + {"execution_id": "execution-1", "condition_key": "condition-1"}, + ) + assert run.id == "wandb-1" + assert api.filters == [ + ("tracking-parking", {"config.execution_id": "execution-1"}) + ] + + +def test_find_run_stops_on_ambiguous_condition_key(): + api = FakeApi( + [ + SimpleNamespace(id="wandb-1", name="first"), + SimpleNamespace(id="wandb-2", name="second"), + ] + ) + with pytest.raises(AmbiguousRunError, match="wandb-1.*wandb-2"): + find_run(api, {"condition_key": "condition-1"}) + + +def test_find_run_supports_legacy_exp_key(): + api = FakeApi([SimpleNamespace(id="wandb-old", name="legacy")]) + run = find_run(api, {"exp_key": "legacy-key"}) + assert run.id == "wandb-old" + assert api.filters == [ + ("tracking-parking", {"config.exp_key": "legacy-key"}) + ] diff --git a/raspi/eval/update_accuracy_from_sam3.py b/raspi/eval/update_accuracy_from_sam3.py new file mode 100644 index 00000000..ed976e82 --- /dev/null +++ b/raspi/eval/update_accuracy_from_sam3.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +SAM3 の GT が揃った後に、既存 W&B run の summary に精度指標を後追い書き込むスクリプト(雛形)。 + +【実行と評価の分離】 +本システムの計測スクリプト(02_run_analysis.py / 04_multi_video_mae.py / line_detection/main.py)は +速度・台数のみを即時記録し、精度系 summary キーは None で確保している。SAM3 の GT が揃った後に +本スクリプトを独立実行し、W&B API 経由で同一 run の summary を更新する(run の再開はしない)。 + +【GT の定義(重要)】 +- 台数の最終 GT は人手の真値(既存 `*_gt.json`)を ground truth とする。 +- SAM3 は per-vehicle 軌跡 / イベント列の生成補助に使い、SAM3 出力をそのまま GT としない。 +- 評価単位(event / detection)は下記 EVAL_UNIT で選択。デフォルトは "event"。 + +使い方: + # 書き込まずに対象 run と予定値だけ確認 + python update_accuracy_from_sam3.py --input eval_results.csv --dry-run + # 実際に summary を更新 + python update_accuracy_from_sam3.py --input eval_results.csv +""" +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1])) # raspi/(common 共有のため) + +# ── 設定 ──────────────────────────────────────────────────────────────────── +WANDB_PROJECT_PATH = "tracking-parking" # 例: "entity/project" or "project" +EVAL_UNIT = "event" # "event" or "detection" +GT_SOURCE = "sam3" + +# 後追いで書き込む summary キー(計測時に None で確保済みのものと一致させる) +ACCURACY_KEYS = ["accuracy", "precision", "recall", "f1", "tp", "fp", "fn", "tn"] +# ──────────────────────────────────────────────────────────────────────────── + + +class AmbiguousRunError(RuntimeError): + """一意にrunを特定できず、誤更新を避けるため処理を止める場合の例外。""" + + +def load_eval_rows(input_path: Path) -> list[dict]: + """SAM3 由来の評価結果テーブル(CSV / JSON)を読み込む。 + + 各行に per-run の accuracy/precision/recall/f1/tp/fp/fn/tn と、 + 対応する `wandb_run_id`、`execution_id`、`condition_key` のいずれかを含める。 + 旧データについては `exp_key` も利用できる。 + """ + if input_path.suffix == ".json": + data = json.loads(input_path.read_text(encoding="utf-8")) + return data if isinstance(data, list) else data.get("rows", []) + elif input_path.suffix == ".csv": + import csv + with open(input_path, newline="", encoding="utf-8") as f: + return list(csv.DictReader(f)) + else: + raise ValueError(f"未対応の入力形式です: {input_path.suffix}(.csv / .json)") + + +def find_run(api, row: dict): + """評価行に対応する W&B run を 1 つ特定する。 + + 優先順: wandb_run_id → execution_id → condition_key → 旧exp_key。 + config検索が複数一致した場合は候補を示す AmbiguousRunError を送出する。 + + Returns: + run or None(0件はWARNしてスキップ) + """ + run_id = row.get("wandb_run_id") + if run_id: + try: + return api.run(f"{WANDB_PROJECT_PATH}/{run_id}") + except Exception as e: # noqa: BLE001 + print(f"[WARN] wandb_run_id={run_id} の run を取得できません: {e}") + return None + + search_candidates = ( + ("execution_id", row.get("execution_id")), + ("condition_key", row.get("condition_key")), + ("exp_key", row.get("exp_key")), + ) + field, value = next( + ((field, value) for field, value in search_candidates if value), + (None, None), + ) + if field is None: + print( + "[WARN] wandb_run_id / execution_id / condition_key / exp_key " + "のいずれも無い行をスキップします" + ) + return None + + runs = list(api.runs(WANDB_PROJECT_PATH, filters={f"config.{field}": value})) + if len(runs) == 0: + print(f"[WARN] {field}={value} に一致する run がありません。スキップします") + return None + if len(runs) > 1: + candidates = ", ".join( + f"{run.id} ({getattr(run, 'name', 'no-name')})" for run in runs + ) + raise AmbiguousRunError( + f"{field}={value} に複数runが一致しました: {candidates}" + ) + return runs[0] + + +def build_summary_update(row: dict) -> dict: + """評価行から summary へ書き込む値を組み立てる。""" + update = {} + for key in ACCURACY_KEYS: + if key in row and row[key] not in (None, ""): + update[key] = row[key] + update["eval_unit"] = EVAL_UNIT + update["gt_source"] = GT_SOURCE + return update + + +def main() -> int: + parser = argparse.ArgumentParser(description="SAM3 精度を既存 W&B run の summary へ後追い書き込む") + parser.add_argument("--input", required=True, help="評価結果テーブル(.csv / .json)") + parser.add_argument("--dry-run", action="store_true", + help="対象 run と書き込み予定値を表示するだけで書き込まない") + args = parser.parse_args() + + input_path = Path(args.input) + if not input_path.exists(): + print(f"[ERROR] 入力ファイルが見つかりません: {input_path}") + return 1 + + rows = load_eval_rows(input_path) + print(f"評価行数: {len(rows)} dry_run={args.dry_run}") + + import wandb + api = wandb.Api() + + updated = 0 + for row in rows: + # TODO(GT 未整備): SAM3 出力から accuracy/precision/... を算出するマッチングロジック本体は + # GT データが揃ってから実装する。ここでは入力テーブルに算出済み値が入っている前提で + # run 特定 → summary 更新のインターフェースのみ確定させている。 + try: + run = find_run(api, row) + except AmbiguousRunError as exc: + print(f"[ERROR] {exc}") + print("誤ったrunへの書き込みを避けるため処理を停止します") + return 2 + if run is None: + continue + + update = build_summary_update(row) + if args.dry_run: + print( + f"[DRY-RUN] run={run.id} " + f"condition_key={row.get('condition_key')} → {update}" + ) + continue + + run.summary.update(update) + updated += 1 + print(f"[OK] run={run.id} summary を更新しました → {update}") + + if not args.dry_run: + print(f"更新した run 数: {updated}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 81cc1a02523c44fc74acb3d1bb52475c17762f0c Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:08:29 +0900 Subject: [PATCH 49/57] =?UTF-8?q?feat:=20=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E7=B2=BE=E5=BA=A6=E8=A9=95=E4=BE=A1=E8=A1=8C=E3=81=AE?= =?UTF-8?q?=E7=94=9F=E6=88=90=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=882=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 Gate3(GT拡張とイベント単位評価)対応。 raspi/eval/build_event_accuracy_rows.py(ブランチ非依存の共通コア、 次のコミットでROI側にも同一内容で配置): - build_eval_row(): PredictedEventのリストとGroundTruthをevent_matching. match_events()で突合し、update_accuracy_from_sam3.py --input が期待する 行(wandb_run_id/execution_id/condition_key + tp/fp/fn/precision/recall/f1) を組み立てる - write_eval_rows(): 行リストをCSV/JSONへ書き出す(拡張子で判定) raspi/line_detection/build_accuracy_report.py(2ライン固有アダプタ、新規): - events_*.json(EventLogger.save_json()の出力)を走査し、event_type/ timestamp_sec/event_idからPredictedEventを組み立て、video_pathから common.ground_truth.load_ground_truth()でGTを読む - GTにevents配列が無い動画(未アノテーション、現状すべて該当)は [WARN]でスキップし、意味のない空GT行を出力に含めない - 複数のevents_*.jsonをまとめて1つの評価行ファイルへ出力 実データでの検証は、G3-3(GT動画のアノテーション、コード実装の範囲外) が完了してから可能になる。今回は合成データでのテストのみ。 uv run pytest raspi/ -q: 161 passed(既存152 + 新規9)。 Co-Authored-By: Claude Sonnet 5 --- raspi/eval/build_event_accuracy_rows.py | 67 ++++++ .../tests/test_build_event_accuracy_rows.py | 122 +++++++++++ raspi/line_detection/build_accuracy_report.py | 96 +++++++++ .../tests/test_build_accuracy_report.py | 192 ++++++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 raspi/eval/build_event_accuracy_rows.py create mode 100644 raspi/eval/tests/test_build_event_accuracy_rows.py create mode 100644 raspi/line_detection/build_accuracy_report.py create mode 100644 raspi/line_detection/tests/test_build_accuracy_report.py diff --git a/raspi/eval/build_event_accuracy_rows.py b/raspi/eval/build_event_accuracy_rows.py new file mode 100644 index 00000000..7acb1951 --- /dev/null +++ b/raspi/eval/build_event_accuracy_rows.py @@ -0,0 +1,67 @@ +"""GT・予測イベントからupdate_accuracy_from_sam3.py入力用の評価行を組み立てる。""" + +from __future__ import annotations + +import csv +import json +from pathlib import Path +from typing import Any, Sequence + +from common.event_matching import PredictedEvent, match_events +from common.ground_truth import GroundTruth + + +def build_eval_row( + predicted: Sequence[PredictedEvent], + gt: GroundTruth, + *, + wandb_run_id: str | None = None, + execution_id: str | None = None, + condition_key: str | None = None, +) -> dict[str, Any]: + """予測イベントとGTを突合し、update_accuracy_from_sam3.py入力用の1行を返す。""" + result = match_events(predicted, gt.events, gt.tolerance_sec) + return { + "wandb_run_id": wandb_run_id, + "execution_id": execution_id, + "condition_key": condition_key, + "tp": result.tp, + "fp": result.fp, + "fn": result.fn, + "precision": result.precision, + "recall": result.recall, + "f1": result.f1, + } + + +def write_eval_rows(rows: Sequence[dict[str, Any]], output_path: str | Path) -> Path: + """評価行をCSVまたはJSONへ書き出す(拡張子で判定)。update_accuracy_from_sam3.py --inputへそのまま渡せる形式。""" + path = Path(output_path) + if path.suffix not in (".json", ".csv"): + raise ValueError(f"未対応の出力形式です: {path.suffix}(.csv / .json)") + + path.parent.mkdir(parents=True, exist_ok=True) + + if path.suffix == ".json": + path.write_text( + json.dumps(list(rows), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return path + + fieldnames = [ + "wandb_run_id", + "execution_id", + "condition_key", + "tp", + "fp", + "fn", + "precision", + "recall", + "f1", + ] + with path.open("w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + return path diff --git a/raspi/eval/tests/test_build_event_accuracy_rows.py b/raspi/eval/tests/test_build_event_accuracy_rows.py new file mode 100644 index 00000000..efa976dc --- /dev/null +++ b/raspi/eval/tests/test_build_event_accuracy_rows.py @@ -0,0 +1,122 @@ +import csv +import json +import sys +from pathlib import Path + +import pytest + + +RASPI_ROOT = Path(__file__).parents[2] +sys.path.insert(0, str(RASPI_ROOT)) + +from common.event_matching import PredictedEvent, match_events +from common.ground_truth import GroundTruth, GtEvent +from eval.build_event_accuracy_rows import build_eval_row, write_eval_rows + + +def make_ground_truth() -> GroundTruth: + return GroundTruth( + path=None, + sha256=None, + gt_in=1, + gt_out=1, + events=( + GtEvent(event_id="gt-in", direction="IN", t_sec=10.0), + GtEvent(event_id="gt-out", direction="OUT", t_sec=30.0), + ), + tolerance_sec=0.5, + ) + + +def make_predicted_events() -> list[PredictedEvent]: + return [ + PredictedEvent(event_id="pred-in", direction="IN", t_sec=10.2), + PredictedEvent(event_id="pred-out", direction="OUT", t_sec=30.0), + PredictedEvent(event_id="pred-fp", direction="IN", t_sec=100.0), + ] + + +def test_build_eval_row_matches_event_matching_and_threads_run_identity(): + predicted = make_predicted_events() + gt = make_ground_truth() + + expected = match_events(predicted, gt.events, gt.tolerance_sec) + row = build_eval_row( + predicted, + gt, + wandb_run_id="run-1", + execution_id="execution-1", + condition_key="condition-1", + ) + + assert row["wandb_run_id"] == "run-1" + assert row["execution_id"] == "execution-1" + assert row["condition_key"] == "condition-1" + assert row["tp"] == expected.tp == 2 + assert row["fp"] == expected.fp == 1 + assert row["fn"] == expected.fn == 0 + assert row["precision"] == expected.precision == pytest.approx(2 / 3) + assert row["recall"] == expected.recall == 1.0 + assert row["f1"] == expected.f1 == pytest.approx(0.8) + + +def test_write_eval_rows_json_round_trips(tmp_path): + rows = [ + { + "wandb_run_id": "run-1", + "execution_id": None, + "condition_key": "condition-1", + "tp": 2, + "fp": 1, + "fn": 0, + "precision": 2 / 3, + "recall": 1.0, + "f1": 0.8, + } + ] + output_path = tmp_path / "nested" / "rows.json" + + written_path = write_eval_rows(rows, output_path) + + assert written_path == output_path + assert json.loads(output_path.read_text(encoding="utf-8")) == rows + + +def test_write_eval_rows_csv_round_trips(tmp_path): + rows = [ + { + "wandb_run_id": "run-1", + "execution_id": None, + "condition_key": "condition-1", + "tp": 2, + "fp": 1, + "fn": 0, + "precision": 2 / 3, + "recall": 1.0, + "f1": 0.8, + } + ] + output_path = tmp_path / "rows.csv" + + write_eval_rows(rows, output_path) + + with output_path.open(newline="", encoding="utf-8") as file: + actual_rows = list(csv.DictReader(file)) + expected_row = { + key: "" if value is None else str(value) + for key, value in rows[0].items() + } + assert actual_rows == [expected_row] + + +def test_write_eval_rows_rejects_unsupported_extension(tmp_path): + with pytest.raises(ValueError): + write_eval_rows([], tmp_path / "rows.txt") + + +def test_write_eval_rows_creates_missing_parent_directories(tmp_path): + output_path = tmp_path / "missing" / "parents" / "rows.json" + + write_eval_rows([], output_path) + + assert output_path.is_file() diff --git a/raspi/line_detection/build_accuracy_report.py b/raspi/line_detection/build_accuracy_report.py new file mode 100644 index 00000000..506736bd --- /dev/null +++ b/raspi/line_detection/build_accuracy_report.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""2ライン検知のイベントJSONからイベント精度評価行を作成する。""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).parents[1])) # raspi/(common 共有のため) + +from common.event_matching import PredictedEvent +from common.ground_truth import load_ground_truth +from eval import build_event_accuracy_rows + + +def collect_eval_rows( + events_dir: str | Path, +) -> tuple[list[dict[str, Any]], int, int]: + """イベントJSONを走査し、GT付き動画の評価行と件数を返す。""" + event_paths = sorted(Path(events_dir).glob("events_*.json")) + rows: list[dict[str, Any]] = [] + + for events_path in event_paths: + data = json.loads(events_path.read_text(encoding="utf-8")) + predicted = [ + PredictedEvent( + event_id=event["event_id"], + direction=event["event_type"], + t_sec=event["timestamp_sec"], + ) + for event in data["events"] + ] + gt = load_ground_truth(data["video_path"]) + if not gt.events: + print( + f"[WARN] {events_path}: per-event GTが無いためスキップします" + ) + continue + + rows.append( + build_event_accuracy_rows.build_eval_row( + predicted, + gt, + wandb_run_id=data.get("wandb_run_id"), + execution_id=data.get("execution_id"), + condition_key=data.get("condition_key"), + ) + ) + + found = len(event_paths) + return rows, found, found - len(rows) + + +def build_accuracy_report( + events_dir: str | Path, + output_path: str | Path, +) -> tuple[list[dict[str, Any]], int, int]: + """イベントJSONから評価行を作り、行があれば出力ファイルへ保存する。""" + rows, found, skipped = collect_eval_rows(events_dir) + if rows: + build_event_accuracy_rows.write_eval_rows(rows, output_path) + return rows, found, skipped + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="2ライン検知のイベントJSONからイベント精度評価行を作成する" + ) + parser.add_argument( + "--events-dir", + default="data/outputs/logs", + help="events_*.jsonがあるディレクトリ", + ) + parser.add_argument( + "--output", + required=True, + help="評価行の出力先(.csv / .json)", + ) + args = parser.parse_args(argv) + + rows, found, skipped = build_accuracy_report(args.events_dir, args.output) + print( + f"events_*.json: {found}件、評価行: {len(rows)}件、スキップ: {skipped}件" + ) + if not rows: + print("[WARN] 評価行が0件のため、何も書き出しませんでした") + else: + print(f"評価行を書き出しました: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/raspi/line_detection/tests/test_build_accuracy_report.py b/raspi/line_detection/tests/test_build_accuracy_report.py new file mode 100644 index 00000000..3bef4bbf --- /dev/null +++ b/raspi/line_detection/tests/test_build_accuracy_report.py @@ -0,0 +1,192 @@ +import json +import sys +from pathlib import Path + + +RASPI_ROOT = Path(__file__).parents[2] +LINE_ROOT = RASPI_ROOT / "line_detection" +sys.path.insert(0, str(LINE_ROOT)) +sys.path.insert(0, str(RASPI_ROOT)) + +from build_accuracy_report import build_accuracy_report + + +def write_events_file( + events_dir: Path, + video_path: Path, + *, + filename: str, + events: list[dict], + wandb_run_id: str | None = None, +) -> Path: + data = { + "video_path": str(video_path), + "events": events, + } + if wandb_run_id is not None: + data["wandb_run_id"] = wandb_run_id + path = events_dir / filename + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +def write_gt_file( + video_path: Path, + *, + events: list[dict] | None = None, + tolerance_sec: float = 0.5, +) -> Path: + data = { + "in": 1, + "out": 1, + "tolerance_sec": tolerance_sec, + } + if events is not None: + data["events"] = events + path = video_path.with_name(f"{video_path.stem}_gt.json") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +def test_build_accuracy_report_writes_expected_row(tmp_path): + events_dir = tmp_path / "logs" + events_dir.mkdir() + video_path = tmp_path / "videos" / "clip.MOV" + write_gt_file( + video_path, + events=[ + {"event_id": "gt-in", "direction": "IN", "t_sec": 10.0}, + {"event_id": "gt-out", "direction": "OUT", "t_sec": 20.0}, + ], + ) + write_events_file( + events_dir, + video_path, + filename="events_clip.json", + wandb_run_id="run-1", + events=[ + { + "track_id": 1, + "event_type": "IN", + "frame_id": 300, + "timestamp_sec": 10.2, + "confidence": "high", + "line2_crossed": True, + "event_id": "pred-in", + }, + { + "track_id": 2, + "event_type": "OUT", + "frame_id": 600, + "timestamp_sec": 100.0, + "confidence": "normal", + "line2_crossed": True, + "event_id": "pred-out", + }, + ], + ) + output_path = tmp_path / "report.json" + + rows, found, skipped = build_accuracy_report(events_dir, output_path) + + assert found == 1 + assert skipped == 0 + assert rows == [ + { + "wandb_run_id": "run-1", + "execution_id": None, + "condition_key": None, + "tp": 1, + "fp": 1, + "fn": 1, + "precision": 0.5, + "recall": 0.5, + "f1": 0.5, + } + ] + assert json.loads(output_path.read_text(encoding="utf-8")) == rows + + +def test_event_file_without_event_gt_is_skipped(tmp_path): + events_dir = tmp_path / "logs" + events_dir.mkdir() + video_path = tmp_path / "videos" / "clip.MOV" + write_gt_file(video_path) + write_events_file( + events_dir, + video_path, + filename="events_clip.json", + events=[], + ) + output_path = tmp_path / "report.json" + + rows, found, skipped = build_accuracy_report(events_dir, output_path) + + assert rows == [] + assert found == 1 + assert skipped == 1 + assert not output_path.exists() + + +def test_event_file_without_gt_is_skipped(tmp_path): + events_dir = tmp_path / "logs" + events_dir.mkdir() + video_path = tmp_path / "videos" / "clip.MOV" + write_events_file( + events_dir, + video_path, + filename="events_clip.json", + events=[], + ) + output_path = tmp_path / "report.json" + + rows, found, skipped = build_accuracy_report(events_dir, output_path) + + assert rows == [] + assert found == 1 + assert skipped == 1 + assert not output_path.exists() + + +def test_multiple_event_files_produce_multiple_rows(tmp_path): + events_dir = tmp_path / "logs" + events_dir.mkdir() + for index in range(2): + video_path = tmp_path / "videos" / f"clip-{index}.MOV" + write_gt_file( + video_path, + events=[ + { + "event_id": f"gt-{index}", + "direction": "IN", + "t_sec": float(index + 1), + } + ], + ) + write_events_file( + events_dir, + video_path, + filename=f"events_clip-{index}.json", + wandb_run_id=f"run-{index}", + events=[ + { + "track_id": index, + "event_type": "IN", + "frame_id": 30 * (index + 1), + "timestamp_sec": float(index + 1), + "confidence": "high", + "line2_crossed": True, + "event_id": f"pred-{index}", + } + ], + ) + output_path = tmp_path / "report.json" + + rows, found, skipped = build_accuracy_report(events_dir, output_path) + + assert found == 2 + assert skipped == 0 + assert len(rows) == 2 + assert [row["wandb_run_id"] for row in rows] == ["run-0", "run-1"] + assert json.loads(output_path.read_text(encoding="utf-8")) == rows From 3c6b4a4dda9c0b33cafe80df7a2e8b8d2a17c26d Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:01:20 +0900 Subject: [PATCH 50/57] =?UTF-8?q?docs:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E3=81=AE=E6=A4=9C=E8=A8=BC=E6=89=8B=E9=A0=86?= =?UTF-8?q?=E3=82=92VERIFICATION.md=E3=81=A8=E3=81=97=E3=81=A6=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検出ロジックが期待どおり動くことを実動画1本で端から端まで確認する手順を、 方式ごとに各ディレクトリへ配置する。従来この内容はdocs.local/配下(gitignore 対象のローカル資産)にしか無く、リポジトリを新しく取得した環境や他の作業者が 参照できなかった。 raspi/line_detection/VERIFICATION.md(新規): - 前提条件(uv sync、gitignore対象のdata/配置、.envの用意) - ユニットテスト → ライン座標のGUI設定 → 配置の目視確認 → 本実行(GT比較) → 出力の確認 → ROI方式との速度比較 → イベント単位の精度評価、の順で記載 - 各手順に実際のコマンドと、期待される標準出力・合格基準を明記した - setup_lines.pyが.envを丸ごと上書きする点、手元の.envが比較条件 (CONFIDENCE_THRESHOLD等)と乖離しうるため速度比較時は環境変数で明示的に 上書きする必要がある点を注意として記載 - build_accuracy_report.pyはGTにevents配列が必要で、現在のIMG_2787_gt.jsonは 台数のみのため評価行0件で終わる。これが現時点では正常な挙動であることを明記 - 「この手順で確認できないこと」(イベント単位精度、tolerance_sec等の妥当性、 実機でのリアルタイム性)を独立した章として残した 記載したコマンド・出力文字列は実装(main.py、event_logger.print_summary、 visualize_lines_and_vehicles.pyの位置引数、build_accuracy_report.pyのCLI)と 突き合わせて確認済み。 ROI方式の検証手順はfeat/mike/89-bbox-analysis-within-roiの raspi/roi-counter/VERIFICATION.mdへ別途追加する。 --- raspi/line_detection/VERIFICATION.md | 230 +++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 raspi/line_detection/VERIFICATION.md diff --git a/raspi/line_detection/VERIFICATION.md b/raspi/line_detection/VERIFICATION.md new file mode 100644 index 00000000..e0ba3dc6 --- /dev/null +++ b/raspi/line_detection/VERIFICATION.md @@ -0,0 +1,230 @@ +# 2ライン方式 検証手順 + +2ライン検知ロジックが期待どおり動くことを、実動画1本に対して端から端まで確認する手順。 +新しい環境で動かすとき、判定ロジックを変更したとき、ROI方式と比較するときに使う。 + +対象ブランチ: `experiment/ucn/two-lines-detection` +対象ディレクトリ: `raspi/line_detection/` + +ROI方式の検証手順は `raspi/roi-counter/VERIFICATION.md`(`feat/mike/89-bbox-analysis-within-roi`)にある。 + +## 0. 前提条件 + +- リポジトリルートで `uv sync` 済みであること +- 実動画がローカルに存在すること。`data/` は `.gitignore` 対象なので、 + リポジトリを新しく取得した環境では別途配置が必要 + - `data/inputs/IMG_2787.MOV` + - GT(正解台数)は ROI方式と共有する: `../roi-counter/data/inputs/configs/IMG_2787_gt.json` +- `.env` が存在すること(`.gitignore` 対象。無ければ 2章で作成する) +- 1回の実行に数分〜十数分かかる(YOLO推論を含むため) + +以降のコマンドは断りがない限り `raspi/line_detection/` をカレントディレクトリとして書く。 + +## 1. ユニットテスト + +実データを流す前に、純ロジックが壊れていないことを確認する。 +リポジトリルートから実行する。 + +```bash +uv run pytest raspi/ -q +``` + +**合格基準**: 全件パス(2026-08-20 時点で 161 passed)。 + +`raspi/common/` は ROI方式とbyte-identicalで共有しているため、ここを変更した場合は +ROI方式側のテストも実行すること。 + +## 2. ライン座標の設定 + +`.env` が無い場合、または画角が変わった場合はGUIで設定し直す。 + +```bash +uv run python line_setup/setup_lines.py --video data/inputs/IMG_2787.MOV +``` + +動画の先頭フレームが表示されるので、次の5点をこの順にクリックする。 + +1. Line1 始点(入口側) +2. Line1 終点(入口側) +3. Line2 始点(駐車場側) +4. Line2 終点(駐車場側) +5. 駐車場基準点(駐車場内の任意の点) + +`r` キーでやり直し、`q` キーまたはウィンドウを閉じると保存される。 + +> **注意**: このツールは `.env` を**丸ごと上書きする**。`MODEL_PATH` や `MARGIN_PX` など +> ライン座標以外の値もテンプレートの既定値で書き戻されるため、実行後に `.env` を開いて +> 意図した値になっているか確認すること。 + +`.env` を手で作る場合は `.env.template` をコピーして `MODEL_PATH` を実在するモデルへ +書き換える(テンプレートはfine-tuned modelを指しているが、手元に無ければ `yolov8s.pt` でよい)。 + +## 3. ライン配置の目視確認 + +実行前に、設定したラインが意図した位置にあるかを確認する。 +引数は位置指定で `<動画パス> [出力動画パス] [開始フレーム] [終了フレーム]`(既定は 0〜300フレーム)。 + +```bash +uv run python visualize_lines_and_vehicles.py data/inputs/IMG_2787.MOV +``` + +出力動画として残す場合、およびフレーム範囲を変える場合: + +```bash +uv run python visualize_lines_and_vehicles.py data/inputs/IMG_2787.MOV debug_vis.mp4 300 600 +``` + +起動時に `Line1` / `Line2` / `駐車場基準点` / `MARGIN_PX` の値が標準出力に表示される。 +これらが `.env` の意図した値になっていること、そして描画されたラインが路面上の +意図した位置にあることを目視で確かめる。 + +## 4. 本実行(GT比較あり) + +```bash +uv run python main.py \ + --input data/inputs/IMG_2787.MOV \ + --gt ../roi-counter/data/inputs/configs/IMG_2787_gt.json +``` + +**確認する項目**: 実行中に `GT比較: count_error=0 (in=0, out=0)` が出たあと、 +末尾に次のサマリーが表示される。 + +``` +============================================================ +処理結果サマリー +============================================================ +入庫: 22 +出庫: 0 +現在駐車台数: 22 +高信頼度イベント: +通常信頼度イベント: +平均処理時間: ms/frame +============================================================ +``` + +- `入庫: 22` / `出庫: 0` がGT(`IMG_2787_gt.json` の `in`/`out`)と一致すること +- `GT比較: count_error=0` であること +- `高信頼度イベント` と `通常信頼度イベント` が両方とも1件以上あること + (Line2通過によるconfidence確定が機能していることの確認) + +`--gt` を省略すると `<動画名>_gt.json` を入力動画と同じディレクトリから自動探索する。 +見つからなければ警告のみでGT比較なしのまま続行する。`--gt` で明示指定したパスが +存在しない場合は起動時にエラーで停止する。 + +## 5. 出力の確認 + +イベントログJSONの整合性を確認する。 + +```bash +python3 -c " +import json, glob +path = sorted(glob.glob('data/outputs/logs/events_*.json'))[-1] +data = json.load(open(path)) +ids = [e['event_id'] for e in data['events']] +print('file:', path) +print('events:', len(ids), '/ unique event_id:', len(set(ids))) +print('high :', sum(e['confidence'] == 'high' for e in data['events'])) +print('normal:', sum(e['confidence'] == 'normal' for e in data['events'])) +print('count_error:', data.get('accuracy', {}).get('count_error')) +" +``` + +**合格基準**: `events` の件数と `unique event_id` の件数が一致すること(イベントIDの重複が無い)。 + +`data/outputs/` には他に次が生成される。 + +| パス | 内容 | +|---|---| +| `logs/events_.json` | イベント列・summary・timing・run識別子・GT比較結果 | +| `logs/events_.csv` | 同内容のCSV | +| `videos/annotated_<動画名>.mp4` | 可視化済み動画(`SAVE_VIDEO=true` 時) | +| `manifests/.json` | run識別子・再現情報・出力パスの相互参照 | + +## 6. ROI方式との速度比較 + +比較可能なrunにするには、両方式で動画・モデル・classes・confidence・IoU・image size・ +tracker・device・warm-up・動画保存/表示設定を揃える必要がある。 +これらから生成される `comparison_key` が一致するrun同士だけを直接比較する。 + +```bash +VEHICLE_CLASSES=2,7 CONFIDENCE_THRESHOLD=0.25 IOU_THRESHOLD=0.7 \ +SAVE_VIDEO=false SHOW_DISPLAY=false SAVE_LOGS=true \ +YOLO_DEVICE=cpu YOLO_IMGSZ=640 YOLO_TRACKER=botsort.yaml WARMUP_FRAMES=30 \ +EXP_DEVICE_NAME=raspi5 EXP_DEVICE_ACCELERATOR=cpu \ +USE_WANDB=true WANDB_MODE=offline WANDB_DIR=data/outputs \ +uv run python main.py \ + --input data/inputs/IMG_2787.MOV \ + --gt ../roi-counter/data/inputs/configs/IMG_2787_gt.json \ + --wandb --device-name raspi5 +``` + +> **注意**: 手元の `.env` は比較条件と異なる値(`CONFIDENCE_THRESHOLD=0.5`、 +> `VEHICLE_CLASSES=2,3,5,7`、`IOU_THRESHOLD=0.3` など)になっていることがある。 +> 比較目的で実行するときは、上記のように**環境変数で明示的に上書きする**こと。 +> `.env.template` 側は比較条件に揃えてある。 + +速度指標は timing schema v2 に従い `read_ms` / `inference_tracking_ms` / +`counting_logic_ms` / `core_ms` / `output_ms` / `end_to_end_ms` に分割される。 +方式比較には warm-up 除外後の `core_ms_p95`、実機のリアルタイム判定には +`end_to_end_ms` と `deadline_miss_rate` を使う。 + +`comparison_key` の一致確認: + +```bash +python3 -c " +import json, glob +p = sorted(glob.glob('data/outputs/manifests/*.json'))[-1] +c = json.load(open(p))['config'] +print('comparison_key:', c['comparison_key']) +print('condition_key :', c['condition_key']) +print('git_dirty :', c['git_dirty']) +" +``` + +**合格基準**: `git_dirty` が `false` であること(作業ツリーが汚れた状態で計測していない)。 + +offline run は後日アップロードできる。 + +```bash +wandb sync +``` + +## 7. イベント単位の精度評価 + +台数(`count_error`)ではなく、個々のイベントがGTと時刻レベルで対応するかを評価する。 + +```bash +uv run python build_accuracy_report.py \ + --events-dir data/outputs/logs \ + --output data/outputs/event_accuracy.csv +``` + +> **前提**: この評価にはGT JSONに `events` 配列(`event_id` / `direction` / `t_sec`)が +> 必要になる。現在の `IMG_2787_gt.json` は台数(`in`/`out`)のみで `events` を持たないため、 +> このコマンドは `[WARN] per-event GTが無いためスキップします` を出して評価行0件で終わる。 +> **これは現時点では正常な挙動**。イベント単位のアノテーションを作成してから使う。 + +## 8. 合格基準のまとめ + +| 確認項目 | 合格基準 | +|---|---| +| ユニットテスト | 全件パス | +| 本実行の台数 | `入庫: 22 出庫: 0`、`count_error=0` | +| confidence | high / normal とも1件以上 | +| `event_id` | 出力JSON内で重複なし | +| `git_dirty` | `false`(速度計測を行う場合) | + +## 9. この手順で確認できないこと + +- **イベント単位の精度(precision / recall / F1)**。GTにイベント単位のアノテーションが + 未整備のため(7章参照) +- **`tolerance_sec`(既定10.0秒)と `MAX_FRAME_GAP`(既定90フレーム)の妥当性**。 + 実データによる分布計測が必要 +- **実機(Raspberry Pi)でのリアルタイム性**。開発機での計測値は参考値にとどまる。 + `EXP_DEVICE_NAME` / `EXP_DEVICE_ACCELERATOR` を実機の値にして計測し直すこと + +## 10. 関連資料 + +- `README.md` — システム構成、設定パラメータ、アルゴリズムの解説 +- `docs/wandb_integration_spec_v2.md` — 実験記録の仕様 +- `raspi/roi-counter/VERIFICATION.md` — ROI方式の検証手順(別ブランチ) From 51a6b1820d1b1277ace9277482be2acb41b1b85c Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:37:45 +0900 Subject: [PATCH 51/57] =?UTF-8?q?docs:=202=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E3=81=AE=E6=A4=9C=E8=A8=BC=E6=89=8B=E9=A0=86?= =?UTF-8?q?=E3=81=B8W&B=E3=83=AD=E3=82=B0=E3=82=A4=E3=83=B3=E3=81=AE?= =?UTF-8?q?=E5=89=8D=E6=8F=90=E3=82=92=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERIFICATION.mdに従って検証したときW&Bへの記録が本当に成立するかを点検した 結果の反映。ROI方式側(feat/mike/89-bbox-analysis-within-roi の 079d24b)と 同じ内容を2ライン側にも揃える。 0章に「W&B(実験記録)について」を新設: - 本手順はWANDB_MODE=offlineなのでログイン無しでも完走するが、その場合 W&Bサーバ上には一切残らないことを明記 - wandb loginの手順と、ログイン状態の確認方法を追加 - WANDB_DIR=data/outputsを省略してはいけない理由を注記。 raspi/common/wandb_logger.py の wandb.init() は dir を渡していないため、 未指定だとwandbがカレントディレクトリ直下へwandb/を作る。data/の外に出ると gitignore対象外になりrun一式を失いやすい(ROI方式で実際に一度失っている)。 なお2ライン側の実行コマンドには元からWANDB_DIRが入っていたため、 コマンド自体の修正は不要だった。 6章のwandb syncの記述を補強: - wandb login済みであることを前提として明示 - 同期していないrunはmanifestにwandb_run_idがあってもW&B上には存在しない点を 注記し、区切りのよいところでまとめて同期するよう促した --- raspi/line_detection/VERIFICATION.md | 33 ++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/raspi/line_detection/VERIFICATION.md b/raspi/line_detection/VERIFICATION.md index e0ba3dc6..08e7e3af 100644 --- a/raspi/line_detection/VERIFICATION.md +++ b/raspi/line_detection/VERIFICATION.md @@ -20,6 +20,32 @@ ROI方式の検証手順は `raspi/roi-counter/VERIFICATION.md`(`feat/mike/89- 以降のコマンドは断りがない限り `raspi/line_detection/` をカレントディレクトリとして書く。 +### W&B(実験記録)について + +速度比較(6章)は `WANDB_MODE=offline` で実行する。**オフライン記録はW&Bへの +ログイン無しで動く**(ローカルに run ディレクトリが作られるだけ)。 + +W&Bサーバへ実際にアップロードするには、事前にログインが必要。 + +```bash +wandb login # 初回のみ。ブラウザでAPIキーを取得して貼り付ける +``` + +ログイン状態は次で確認できる。 + +```bash +grep -q "api.wandb.ai" ~/.netrc && echo "ログイン済み" || echo "未ログイン" +``` + +未ログインのままでも検証手順そのものは完走する。ただし**offline runはローカルに +溜まり続けるだけで、W&B上では一切参照できない**。manifestに記録される +`wandb_run_id` も、同期するまではW&B上に対応する実体が無い点に注意すること。 + +> **`WANDB_DIR=data/outputs` を省略しないこと。** `raspi/common/wandb_logger.py` の +> `wandb.init()` は `dir` を渡していないため、未指定だとwandbが**カレントディレクトリ直下**に +> `wandb/` を作る。`data/` の外に出るとgitignoreの対象外になり、run一式を失いやすい +> (ROI方式で実際に一度失っている)。 + ## 1. ユニットテスト 実データを流す前に、純ロジックが壊れていないことを確認する。 @@ -183,12 +209,15 @@ print('git_dirty :', c['git_dirty']) **合格基準**: `git_dirty` が `false` であること(作業ツリーが汚れた状態で計測していない)。 -offline run は後日アップロードできる。 +offline run は後日アップロードできる(`wandb login` 済みであること。0章参照)。 ```bash -wandb sync +wandb sync data/outputs/wandb/offline-run-- ``` +同期していない run は、manifest に `wandb_run_id` が記録されていてもW&B上には存在しない。 +区切りのよいところでまとめて同期しておくこと。 + ## 7. イベント単位の精度評価 台数(`count_error`)ではなく、個々のイベントがGTと時刻レベルで対応するかを評価する。 From a93ce6a3cc97c2bd7817a515756f87454d23daab Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:06:41 +0900 Subject: [PATCH 52/57] =?UTF-8?q?docs:=20W&B=E3=81=AE=E9=81=8B=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E9=87=9D=E3=82=924=E7=82=B9=E3=81=AE=E6=B1=BA?= =?UTF-8?q?=E5=AE=9A=E3=81=A8=E3=81=97=E3=81=A6=E6=98=8E=E6=96=87=E5=8C=96?= =?UTF-8?q?=EF=BC=882=E3=83=A9=E3=82=A4=E3=83=B3=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROI方式(feat/mike/89-bbox-analysis-within-roi の 8d0d042)と同じ4点を 2ライン方式側へも反映する。ネットワーク断時のwandb.init()の実測が根拠。 1. 一次記録はローカル成果物、W&Bは二次的な閲覧先(0章冒頭) manifests/はUSE_WANDBの値に関係なく書かれ、logs/events_.jsonには 速度統計58キーがtiming_summaryとして保存される。W&Bを無効にしても方式の比較と 再現に必要な数値は失われない。 2. WANDB_MODEはoffline固定(online不採用、0章) wandb 0.28.0で実測したところ、到達不能な宛先に対してonlineのwandb.init()が ブロックし続けた(接続拒否で4分超、DNS解決失敗で100秒超)。WANDB_INIT_TIMEOUTも Settings(init_timeout)も効かない。打ち切って例外を上げた場合、wandb.init()が try/exceptで包まれておらずExperimentLoggerの生成がmain.pyのtry:(314行目)より 前にあるため、1フレームも処理せず終了しmanifestも残らない。実測表を残した。 3. 本番運用ではW&Bを有効にしない(0章) 2ラインのmain.pyは検証と本番運用の両方で使い、W&Bは--wandb / USE_WANDB=trueで 明示的に有効化したときだけ動く。本番運用ではどちらも付けない、と決定した。 ROI方式のmain.pyがW&B非対応のままであるのと同じ理由による。 4. 精度検証runをW&Bへ残す(4章、推奨アクション#1の本体) 4章「本実行(GT比較あり)」のコマンドには--wandbもUSE_WANDBも無く、count_error=0を 確認する精度検証の本体がW&Bに残らなかった。USE_WANDB=true WANDB_MODE=offline WANDB_DIR=data/outputsを追加した。あわせて6章へ未同期run(*.wandb.syncedが無い ディレクトリ)の一覧コマンドを追加し、8章の合格基準にも同期の行を足した。 docs/wandb_integration_spec_v2.mdの§1.8(ネットワークの無い環境を第一級ユースケース とする原則)へ、offline固定と本番運用でのフラグ非使用を決定として追記した。 init成功後に切断された場合の挙動は再現できておらず、未検証である旨を0章に明記した。 Co-Authored-By: Claude Opus 5 --- raspi/line_detection/VERIFICATION.md | 72 ++++++++++++++++++- .../docs/wandb_integration_spec_v2.md | 2 + 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/raspi/line_detection/VERIFICATION.md b/raspi/line_detection/VERIFICATION.md index 08e7e3af..98aa9e21 100644 --- a/raspi/line_detection/VERIFICATION.md +++ b/raspi/line_detection/VERIFICATION.md @@ -22,7 +22,13 @@ ROI方式の検証手順は `raspi/roi-counter/VERIFICATION.md`(`feat/mike/89- ### W&B(実験記録)について -速度比較(6章)は `WANDB_MODE=offline` で実行する。**オフライン記録はW&Bへの +**一次記録はローカル成果物であり、W&Bは二次的な閲覧先である。** +`manifests/{execution_id}.json` は `USE_WANDB` の値に関係なく書かれ、 +`logs/events_.json` には速度統計58キーが `timing_summary` として保存される。 +W&Bを無効にしても、方式の比較と再現に必要な数値は失われない。 +この前提を保つ限り、W&B側の障害が検証の進行を止めることはない。 + +W&Bを付けて実行する手順(4章・6章)は `WANDB_MODE=offline` で実行する。**オフライン記録はW&Bへの ログイン無しで動く**(ローカルに run ディレクトリが作られるだけ)。 W&Bサーバへ実際にアップロードするには、事前にログインが必要。 @@ -46,6 +52,52 @@ grep -q "api.wandb.ai" ~/.netrc && echo "ログイン済み" || echo "未ログ > `wandb/` を作る。`data/` の外に出るとgitignoreの対象外になり、run一式を失いやすい > (ROI方式で実際に一度失っている)。 +### `WANDB_MODE=online` を採用しない理由(2026-08-26 決定) + +`online` は使わない。 +ネットワークへ到達できない環境で `wandb.init()` がハングし、検証そのものが進まなくなるため。 + +2026-08-25 に wandb 0.28.0 で実測した結果を示す。 +`WANDB_BASE_URL` を到達不能な宛先へ差し替えて、ネットワーク断の2つの形(DNS解決の失敗、接続拒否)を再現した。 + +| 条件 | 結果 | +|---|---| +| `WANDB_MODE=offline` + 到達不能 | 0.5秒で init 成功。`offline-run-…` が生成される | +| `WANDB_MODE=online` + 接続拒否 | 4分を超えてブロック(100秒で強制終了) | +| `WANDB_MODE=online` + DNS解決失敗 | 100秒を超えてブロック | +| 上記に `WANDB_INIT_TIMEOUT=15` | 効かない(ブロックが続く) | +| 上記に `wandb.Settings(init_timeout=10)` | 効かない(ブロックが続く) | +| 上記に `wandb.Settings(x_graphql_retry_max=1)` | 19.2秒で `wandb.errors.errors.Error` を送出 | + +`init_timeout` は既定90秒が設定されているにもかかわらず、この失敗形態では発火しなかった。 +リトライを打ち切るには `x_graphql_retry_max`(`x_` 接頭辞は内部オプション)に頼るしかない。 + +打ち切って例外を上げた場合、現在の実装では計測が丸ごと失われる。 +`raspi/common/wandb_logger.py` の `wandb.init()` は try/except で包んでおらず、 +`ExperimentLogger` の生成は `main.py` のフレーム処理を囲む `try:` より前にある。 +例外は `try`/`finally` の外側で発生するので、1フレームも処理しないまま終了し、 +`manifests/` も残らない。 + +`offline` にはこの問題が無い。 +ネットワークの不在が正常系だからである。 +アップロードは `wandb sync` で任意のタイミングに行える。 + +将来 `online` へ切り替える場合は、`wandb.init()` を try/except で包み、 +失敗時は `enabled=False` へ降格して計測本体を続行する改修が前提になる。 +`ExperimentLogger` は `enabled=False` で完全な no-op になるため、降格の受け皿は既にある。 + +なお init 成功後に切断された場合の挙動は確認していない。 +wandbはローカルへバッファして再送する設計だが、`finish()` でのフラッシュ待ちを含めて未検証である。 + +### 本番運用ではW&Bを有効にしない(2026-08-26 決定) + +`main.py` は検証と本番運用の両方で使う。 +W&Bは `--wandb` または `USE_WANDB=true` で明示的に有効化したときだけ動くので、 +本番運用ではどちらも付けない。 +ネットワーク断が入出庫カウントの停止に直結する状態を、24/7で動く監視系に持ち込まないため。 + +実運用データの記録が必要になった場合は、上に書いた降格処理を実装した上で改めて判断する。 + ## 1. ユニットテスト 実データを流す前に、純ロジックが壊れていないことを確認する。 @@ -107,11 +159,18 @@ uv run python visualize_lines_and_vehicles.py data/inputs/IMG_2787.MOV debug_vis ## 4. 本実行(GT比較あり) ```bash -uv run python main.py \ +USE_WANDB=true WANDB_MODE=offline WANDB_DIR=data/outputs \ + WANDB_PROJECT=tracking-parking \ + uv run python main.py \ --input data/inputs/IMG_2787.MOV \ --gt ../roi-counter/data/inputs/configs/IMG_2787_gt.json ``` +`count_error` と confidence の内訳はこの run のW&B summary に入る。 +W&Bを付けずに実行すると、合格判定の根拠がローカル成果物にしか残らず、 +ROI方式(`04_multi_video_mae.py` が既定でW&Bへ記録する)と粒度が揃わない。 +完走したら6章と同じ手順で `wandb sync` する。 + **確認する項目**: 実行中に `GT比較: count_error=0 (in=0, out=0)` が出たあと、 末尾に次のサマリーが表示される。 @@ -218,6 +277,14 @@ wandb sync data/outputs/wandb/offline-run-- 同期していない run は、manifest に `wandb_run_id` が記録されていてもW&B上には存在しない。 区切りのよいところでまとめて同期しておくこと。 +未同期の run は次で一覧できる(同期済みの run には `*.wandb.synced` が置かれる)。 + +```bash +for d in data/outputs/wandb/offline-run-*; do + find "$d" -maxdepth 1 -name '*.wandb.synced' | grep -q . || echo "未同期: $d" +done +``` + ## 7. イベント単位の精度評価 台数(`count_error`)ではなく、個々のイベントがGTと時刻レベルで対応するかを評価する。 @@ -242,6 +309,7 @@ uv run python build_accuracy_report.py \ | confidence | high / normal とも1件以上 | | `event_id` | 出力JSON内で重複なし | | `git_dirty` | `false`(速度計測を行う場合) | +| W&B同期 | 未同期の offline run が残っていないこと(6章) | ## 9. この手順で確認できないこと diff --git a/raspi/line_detection/docs/wandb_integration_spec_v2.md b/raspi/line_detection/docs/wandb_integration_spec_v2.md index 8441a2f2..d4f39177 100644 --- a/raspi/line_detection/docs/wandb_integration_spec_v2.md +++ b/raspi/line_detection/docs/wandb_integration_spec_v2.md @@ -43,6 +43,8 @@ 8. **ネットワークの無い環境(Raspberry Pi 実機)での計測を第一級ユースケースとする。** - `WANDB_MODE` 環境変数(`online` / `offline`)を尊重する。offline 時はローカルに記録され、後日 `wandb sync` でアップロードできる。 - README(または各スクリプトの docstring)に offline 計測 → sync の手順を 3〜4 行で記載すること。 + - **運用は `offline` に固定する(2026-08-26 決定)**。`online` はネットワークへ到達できない環境で `wandb.init()` がハングし、`WANDB_INIT_TIMEOUT` でも `Settings(init_timeout)` でも打ち切れない。実測値と根拠は `raspi/line_detection/VERIFICATION.md` の0章に記載した。 + - **本番運用では `--wandb` / `USE_WANDB` を付けない(2026-08-26 決定)**。ネットワーク断が入出庫カウントの停止に直結する状態を、24/7で動く監視系へ持ち込まないため。ROI方式の `main.py` も同じ理由でW&B非対応のままとする。 --- From 77cb5105737797a921851e940beca51d49e88ae5 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:53:42 +0900 Subject: [PATCH 53/57] =?UTF-8?q?fix:=20=E6=99=82=E9=96=93=E7=AA=93?= =?UTF-8?q?=E3=82=92=E3=83=95=E3=83=AC=E3=83=BC=E3=83=A0=E5=9F=BA=E6=BA=96?= =?UTF-8?q?=E3=81=8B=E3=82=89=E7=A7=92=E5=9F=BA=E6=BA=96=E3=81=B8=E5=A4=89?= =?UTF-8?q?=E6=9B=B4=EF=BC=882=E3=83=A9=E3=82=A4=E3=83=B3=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROI方式(feat/mike/89-bbox-analysis-within-roi の 645f1c0)と同じ変更を、 2ライン方式のMAX_FRAME_GAPとCLEANUP_THRESHOLDへ適用する。 どちらもフレーム数で持っていたが、意味的には時間の量である(Line1とLine2の 通過を何秒以内なら対応付けるか、何秒見失ったら破棄するか)。.env.templateが 既に「3秒@30fps」「5秒@30fps」と注記していたとおり、意図は最初から秒だった。 フレーム数で固定すると、同じ設定値が撮影fpsによって別の長さを意味する。 90フレームは30fpsで3秒、10fpsでは9秒になる。検証に使ってきた動画は30fpsだが、 実機のRaspberry Piは10fps前後で動くため、実機では窓が意図の約3倍になっていた。 common/time_windows.py: - ROI側と byte-identical に配置(frames_from_seconds とそのテスト21件) detection/config.py: - max_frame_gap / cleanup_threshold を max_frame_gap_sec / cleanup_threshold_sec へ変更。既定は3.0秒 / 5.0秒で、旧既定を30fpsで換算した値と一致する - 旧env varが残ったまま新しい名前が無い場合はValueErrorで移行を促す。MARGIN→ MARGIN_PXの前例に倣った。黙って無視すると窓の長さが既定値へ戻ってしまうため - validateは秒に対して0以上を検査する main.py: - fps確定後にframes_from_secondsで変換し、VehicleTrackerへフレーム数を渡す - 起動ログへフレーム数と秒数とfpsを併記する - run_configへ秒(仕様)と変換後のフレーム数(実際の挙動)を両方記録。 condition_keyに入るのはフレーム数のほうで、キー名は据え置いたため過去runとの 比較の意味は変わらない VehicleTrackerはフレーム基準のまま据え置いた。current_frameを受け取って動く以上、 内部の単位はフレームが自然であり、変換は設定の読み込み側の責務とした。 既存のtrackerテスト(max_frame_gap=3 等)もそのまま通る。 .env.template / line_setup/setup_lines.py の案内文、README、VERIFICATION.mdの 「確認できないこと」も秒表記へ更新した。 テスト: raspi/ 全体で188 passed(Gate 3時点161 → time_windows 21件と config移行テスト6件を追加)。 Co-Authored-By: Claude Opus 5 --- raspi/common/tests/test_time_windows.py | 59 +++++++++++++++ raspi/common/time_windows.py | 50 +++++++++++++ raspi/line_detection/.env.template | 4 +- raspi/line_detection/README.md | 19 ++++- raspi/line_detection/VERIFICATION.md | 2 +- raspi/line_detection/detection/config.py | 35 ++++++--- .../line_detection/line_setup/setup_lines.py | 2 +- raspi/line_detection/main.py | 23 ++++-- .../tests/test_config_time_windows.py | 74 +++++++++++++++++++ 9 files changed, 246 insertions(+), 22 deletions(-) create mode 100644 raspi/common/tests/test_time_windows.py create mode 100644 raspi/common/time_windows.py create mode 100644 raspi/line_detection/tests/test_config_time_windows.py diff --git a/raspi/common/tests/test_time_windows.py b/raspi/common/tests/test_time_windows.py new file mode 100644 index 00000000..ee21fabf --- /dev/null +++ b/raspi/common/tests/test_time_windows.py @@ -0,0 +1,59 @@ +import math + +import pytest + +from common.time_windows import frames_from_seconds + + +@pytest.mark.parametrize( + "seconds, fps, expected", + [ + # 30fpsでは、これまでフレーム単位で持っていた既定値と一致する + # (ROI: cleanup 150 / candidate 300、2ライン: max_frame_gap 90)。 + (5.0, 30.0, 150), + (10.0, 30.0, 300), + (3.0, 30.0, 90), + # 実機に近い10fpsでは、同じ秒数がフレーム数として1/3になる。 + (5.0, 10.0, 50), + (10.0, 10.0, 100), + (3.0, 10.0, 30), + # 端数は四捨五入する。 + (1.0, 24.0, 24), + (0.5, 25.0, 13), + (2.5, 10.0, 25), + ], +) +def test_秒とfpsからフレーム数を導出する(seconds, fps, expected): + assert frames_from_seconds(seconds, fps) == expected + + +def test_ゼロ秒はゼロフレームのまま返す(): + """0は「即座に失効させる」という指定であり、1へ繰り上げてはいけない。""" + assert frames_from_seconds(0, 30.0) == 0 + assert frames_from_seconds(0.0, 10.0) == 0 + + +def test_正の秒数は丸めで消えず最低1フレームになる(): + """窓を指定したのに0フレーム=無効化される事故を防ぐ。""" + assert frames_from_seconds(0.001, 10.0) == 1 + assert frames_from_seconds(0.01, 1.0) == 1 + + +def test_同じ秒数はfpsが変わっても同じ時間長を表す(): + seconds = 5.0 + for fps in (10.0, 15.0, 24.0, 30.0, 60.0): + frames = frames_from_seconds(seconds, fps) + assert math.isclose(frames / fps, seconds, rel_tol=0.05) + + +@pytest.mark.parametrize("seconds", [-1.0, -0.001, float("nan"), float("inf")]) +def test_不正なsecondsを拒否する(seconds): + with pytest.raises(ValueError, match="seconds"): + frames_from_seconds(seconds, 30.0) + + +@pytest.mark.parametrize("fps", [0, 0.0, -30.0, float("nan"), float("inf")]) +def test_不正なfpsを拒否する(fps): + """fpsが取れないまま変換すると、窓の長さが黙って狂う。""" + with pytest.raises(ValueError, match="fps"): + frames_from_seconds(5.0, fps) diff --git a/raspi/common/time_windows.py b/raspi/common/time_windows.py new file mode 100644 index 00000000..75f19b37 --- /dev/null +++ b/raspi/common/time_windows.py @@ -0,0 +1,50 @@ +"""フレーム単位の時間窓を、撮影fpsに依存しない秒指定から導出する。 + +trackのcleanup、候補状態の失効、Line1とLine2の対応付けに使う「窓」は、 +本来「何秒見失ったか」「何秒以内に通過したか」で決まる量である。 +これをフレーム数のまま設定に持つと、同じ設定値が撮影fpsによって別の長さを +意味してしまう(150フレームは30fpsで5秒、10fpsでは15秒)。 + +検証に使ってきた動画は30fps、実機のRaspberry Piは10fps前後で動くため、 +この差は実運用と検証のあいだへ黙ったまま入り込む。設定を秒で持ち、動画を +開いた時点のfpsからフレーム数へ変換することでこれを防ぐ。 + +変換後のフレーム数はrun configへ併記し、実際に何フレームで動いたかを後から +確認できるようにする。 +""" + +import math + +__all__ = ["frames_from_seconds"] + + +def frames_from_seconds(seconds: float, fps: float) -> int: + """秒で指定した時間窓を、そのfpsにおけるフレーム数へ変換する。 + + 丸めは四捨五入(0.5は切り上げ)。0秒は0フレームのまま返し、「即座に失効 + させる」という指定を保つ。正の秒数が丸めで0フレームになる場合だけは + 1フレームへ切り上げる(窓を指定したのに無効化される事故を防ぐため)。 + + Args: + seconds: 時間窓の長さ(秒)。0以上の有限値。 + fps: 変換先のフレームレート。正の有限値。 + + Returns: + フレーム数。 + + Raises: + ValueError: secondsが負または非有限、fpsが正の有限値でない場合。 + """ + seconds = float(seconds) + if math.isnan(seconds) or math.isinf(seconds): + raise ValueError(f"secondsは有限の数である必要があります: {seconds!r}") + if seconds < 0: + raise ValueError(f"secondsは0以上である必要があります: {seconds!r}") + + fps = float(fps) + if math.isnan(fps) or math.isinf(fps) or fps <= 0: + raise ValueError(f"fpsは正の有限値である必要があります: {fps!r}") + + if seconds == 0.0: + return 0 + return max(1, math.floor(seconds * fps + 0.5)) diff --git a/raspi/line_detection/.env.template b/raspi/line_detection/.env.template index da081f02..98cb3a33 100644 --- a/raspi/line_detection/.env.template +++ b/raspi/line_detection/.env.template @@ -34,8 +34,8 @@ PARKING_REF_Y=100 # 検知パラメータ MARGIN_PX=5.0 # 判定保留帯の半幅(px)。3cスイープ採用値 ENDPOINT_MARGIN_PX=0.0 # 有限線分判定の端点許容量(px)。Phase 3の既定(変更しない) -MAX_FRAME_GAP=90 # 3秒@30fps -CLEANUP_THRESHOLD=150 # 5秒@30fps +MAX_FRAME_GAP_SEC=3.0 # 動画のfpsからフレーム数へ変換する +CLEANUP_THRESHOLD_SEC=5.0 # 同上 # 処理方式: hybrid固定 METHOD=hybrid diff --git a/raspi/line_detection/README.md b/raspi/line_detection/README.md index 7d26f1eb..decbb996 100644 --- a/raspi/line_detection/README.md +++ b/raspi/line_detection/README.md @@ -284,10 +284,23 @@ CONFIDENCE_THRESHOLD=0.3 # 検知信頼度閾値(0.0-1.0) ```bash MARGIN_PX=5.0 # 判定保留帯の半幅(px)。3cスイープ採用値 ENDPOINT_MARGIN_PX=0.0 # 有限線分判定の端点許容量(px) -MAX_FRAME_GAP=90 # Line1とLine2の最大フレーム差(3秒@30fps) -CLEANUP_THRESHOLD=150 # 古い追跡をクリーンアップ(5秒@30fps) +MAX_FRAME_GAP_SEC=3.0 # Line1とLine2の通過を対応付ける最大の時間差(秒) +CLEANUP_THRESHOLD_SEC=5.0 # 古い追跡をクリーンアップするまでの未更新時間(秒) ``` +**時間窓は秒で指定する。** 動画を開いた時点のfpsからフレーム数へ変換する +(`common/time_windows.py`の`frames_from_seconds`)。フレーム数で直接持つと、 +同じ設定値が撮影fpsによって別の長さを意味してしまう。90フレームは30fpsで3秒だが、 +10fpsでは9秒になる。検証に使ってきた動画は30fps、実機のRaspberry Piは10fps前後で +動くため、フレーム基準のままでは検証と実運用のあいだに黙って差が入る。 + +既定値(3.0秒・5.0秒)は旧既定のフレーム数(90・150)を30fpsで換算した値と一致する。 +30fpsの動画では挙動が変わらない。runには秒(`max_frame_gap_sec`)と変換後の +フレーム数(`max_frame_gap`)の両方を記録し、`condition_key`にはフレーム数が入る。 + +旧`MAX_FRAME_GAP`/`CLEANUP_THRESHOLD`が`.env`に残っている場合は、黙って無視せず +起動時にエラーで移行を促す。 + ### 出力設定 ```bash @@ -353,7 +366,7 @@ IF Line2のみ交差(Line1交差なし): **考えられる原因:** 1. ライン位置が適切でない → `setup_lines.py`で再設定 2. `MARGIN_PX`が大きすぎる/小さすぎる → `.env`で調整(px単位) -3. `MAX_FRAME_GAP`が適切でない → 車両の通過速度に合わせて調整 +3. `MAX_FRAME_GAP_SEC`が適切でない → 車両の通過速度に合わせて調整 ## 参考 diff --git a/raspi/line_detection/VERIFICATION.md b/raspi/line_detection/VERIFICATION.md index 98aa9e21..31202f6b 100644 --- a/raspi/line_detection/VERIFICATION.md +++ b/raspi/line_detection/VERIFICATION.md @@ -315,7 +315,7 @@ uv run python build_accuracy_report.py \ - **イベント単位の精度(precision / recall / F1)**。GTにイベント単位のアノテーションが 未整備のため(7章参照) -- **`tolerance_sec`(既定10.0秒)と `MAX_FRAME_GAP`(既定90フレーム)の妥当性**。 +- **`tolerance_sec`(既定10.0秒)と `MAX_FRAME_GAP_SEC`(既定3.0秒)の妥当性**。 実データによる分布計測が必要 - **実機(Raspberry Pi)でのリアルタイム性**。開発機での計測値は参考値にとどまる。 `EXP_DEVICE_NAME` / `EXP_DEVICE_ACCELERATOR` を実機の値にして計測し直すこと diff --git a/raspi/line_detection/detection/config.py b/raspi/line_detection/detection/config.py index 6007a4cb..ffcd050e 100644 --- a/raspi/line_detection/detection/config.py +++ b/raspi/line_detection/detection/config.py @@ -41,8 +41,11 @@ class Config: # 検知パラメータ margin_px: float # 判定保留帯の半幅(px) endpoint_margin_px: float # 有限線分判定における端点の許容量(px) - max_frame_gap: int # Line1とLine2の最大フレーム差 - cleanup_threshold: int # 古い追跡をクリーンアップするフレーム数 + # 時間窓は秒で持ち、動画のfpsからフレーム数へ変換する + # (common/time_windows.py の frames_from_seconds)。フレーム数で直接持つと、 + # 同じ設定値が撮影fpsによって別の長さを意味してしまう。 + max_frame_gap_sec: float # Line1とLine2の通過を対応付ける最大の時間差(秒) + cleanup_threshold_sec: float # 古い追跡をクリーンアップするまでの未更新時間(秒) iou_threshold: float # トラッキングのIOU閾値 # 処理方式 @@ -127,8 +130,20 @@ def from_env(cls, env_path: str = None): ) margin_px = float(os.getenv("MARGIN_PX", "5.0")) endpoint_margin_px = float(os.getenv("ENDPOINT_MARGIN_PX", "0.0")) - max_frame_gap = int(os.getenv("MAX_FRAME_GAP", "90")) - cleanup_threshold = int(os.getenv("CLEANUP_THRESHOLD", "150")) + for old_name, new_name, old_example, new_example in ( + ("MAX_FRAME_GAP", "MAX_FRAME_GAP_SEC", "90", "3.0"), + ("CLEANUP_THRESHOLD", "CLEANUP_THRESHOLD_SEC", "150", "5.0"), + ): + if os.getenv(old_name) is not None and os.getenv(new_name) is None: + raise ValueError( + f"{old_name} は廃止されました。{new_name}(単位: 秒)へ移行してください。\n" + f" 旧: {old_name}={old_example} はフレーム数で、30fpsを前提とした値でした。\n" + f" 新: {new_name} は秒で指定し、動画のfpsからフレーム数へ変換します。\n" + f" 30fps相当の設定は {new_name}={new_example} です。\n" + f" .env の {old_name} 行を {new_name} へ書き換えてください。" + ) + max_frame_gap_sec = float(os.getenv("MAX_FRAME_GAP_SEC", "3.0")) + cleanup_threshold_sec = float(os.getenv("CLEANUP_THRESHOLD_SEC", "5.0")) iou_threshold = float(os.getenv("IOU_THRESHOLD", "0.3")) # 処理方式 @@ -149,8 +164,8 @@ def from_env(cls, env_path: str = None): parking_ref_point=parking_ref_point, margin_px=margin_px, endpoint_margin_px=endpoint_margin_px, - max_frame_gap=max_frame_gap, - cleanup_threshold=cleanup_threshold, + max_frame_gap_sec=max_frame_gap_sec, + cleanup_threshold_sec=cleanup_threshold_sec, iou_threshold=iou_threshold, method=method, save_video=save_video, @@ -183,11 +198,11 @@ def validate(self): if self.endpoint_margin_px < 0: errors.append(f"endpoint_margin_pxは0以上の値である必要があります: {self.endpoint_margin_px}") - if self.max_frame_gap < 0: - errors.append(f"max_frame_gapは正の値である必要があります: {self.max_frame_gap}") + if self.max_frame_gap_sec < 0: + errors.append(f"max_frame_gap_secは0以上の値である必要があります: {self.max_frame_gap_sec}") - if self.cleanup_threshold < 0: - errors.append(f"cleanup_thresholdは正の値である必要があります: {self.cleanup_threshold}") + if self.cleanup_threshold_sec < 0: + errors.append(f"cleanup_threshold_secは0以上の値である必要があります: {self.cleanup_threshold_sec}") # 処理方式のチェック if self.method != "hybrid": diff --git a/raspi/line_detection/line_setup/setup_lines.py b/raspi/line_detection/line_setup/setup_lines.py index a732d8ce..c477876c 100755 --- a/raspi/line_detection/line_setup/setup_lines.py +++ b/raspi/line_detection/line_setup/setup_lines.py @@ -259,7 +259,7 @@ def save_to_env(self): # 検知パラメータ MARGIN_PX=5.0 ENDPOINT_MARGIN_PX=0.0 -MAX_FRAME_GAP=90 # 3秒@30fps +MAX_FRAME_GAP_SEC=3.0 # 秒。動画のfpsからフレーム数へ変換する CLEANUP_THRESHOLD=150 # 5秒@30fps # 処理方式: hybrid固定 diff --git a/raspi/line_detection/main.py b/raspi/line_detection/main.py index 512b2659..d874b3d0 100755 --- a/raspi/line_detection/main.py +++ b/raspi/line_detection/main.py @@ -25,6 +25,7 @@ from result_output.video_writer import VideoAnnotator from result_output.event_logger import EventLogger from common.frame_stats import compute_timing_stats +from common.time_windows import frames_from_seconds from common.frame_timing import ( DEFAULT_WARMUP_FRAMES, TIMING_SCHEMA_VERSION, @@ -191,12 +192,20 @@ def process_video( print(f"✓ 動画情報: {width}x{height} @ {fps}fps, {total_frames}フレーム") + # 時間窓(秒)をこの動画のfpsにおけるフレーム数へ変換する。 + max_frame_gap = frames_from_seconds(config.max_frame_gap_sec, fps) + cleanup_threshold = frames_from_seconds(config.cleanup_threshold_sec, fps) + # トラッカーを初期化 tracker = VehicleTracker( - max_frame_gap=config.max_frame_gap, - cleanup_threshold=config.cleanup_threshold + max_frame_gap=max_frame_gap, + cleanup_threshold=cleanup_threshold + ) + print( + f"✓ トラッカー初期化: " + f"max_frame_gap={max_frame_gap}フレーム({config.max_frame_gap_sec}秒), " + f"cleanup={cleanup_threshold}フレーム({config.cleanup_threshold_sec}秒) @ {fps}fps" ) - print(f"✓ トラッカー初期化: max_frame_gap={config.max_frame_gap}, cleanup={config.cleanup_threshold}") # ライン交差検知器を初期化 detector = LineCrossingDetector( @@ -239,10 +248,10 @@ def process_video( input_type = "file" if isinstance(video_path, str) else "camera" dataset = Path(str(video_path)).stem if isinstance(video_path, str) else f"camera_{video_path}" exp_params = { - "cleanup_threshold": config.cleanup_threshold, + "cleanup_threshold": cleanup_threshold, "margin_px": config.margin_px, "endpoint_margin_px": config.endpoint_margin_px, - "max_frame_gap": config.max_frame_gap, + "max_frame_gap": max_frame_gap, "crossing_method": CROSSING_METHOD, } reproducibility = collect_reproducibility_info() @@ -278,6 +287,10 @@ def process_video( "save_logs": config.save_logs, "show_display": config.show_display, "timing_schema_version": TIMING_SCHEMA_VERSION, + # 秒(仕様)と、上のexp_params側に入る変換後のフレーム数(実際の挙動)を + # 両方残す。condition_keyに入るのはフレーム数のほう。 + "max_frame_gap_sec": config.max_frame_gap_sec, + "cleanup_threshold_sec": config.cleanup_threshold_sec, **exp_params, **reproducibility, **build_ground_truth_config(gt), diff --git a/raspi/line_detection/tests/test_config_time_windows.py b/raspi/line_detection/tests/test_config_time_windows.py new file mode 100644 index 00000000..dfef4dfb --- /dev/null +++ b/raspi/line_detection/tests/test_config_time_windows.py @@ -0,0 +1,74 @@ +"""時間窓(秒)の設定読み込みに関するテスト。 + +フレーム基準から秒基準へ移行したため、旧env varが残った.envを黙って +無視しないことと、既定値が旧既定の30fps換算と一致することを確認する。 +""" +import pytest + +from detection.config import Config + +# from_env が必須とする最小限のenv。 +REQUIRED = { + "HOME_DIR": "/tmp/line-detection-test", + "MODEL_PATH": "yolov8s.pt", + "LINE1_X1": "0", "LINE1_Y1": "0", "LINE1_X2": "100", "LINE1_Y2": "0", + "LINE2_X1": "0", "LINE2_Y1": "50", "LINE2_X2": "100", "LINE2_Y2": "50", + "PARKING_REF_X": "50", "PARKING_REF_Y": "100", +} + +TIME_WINDOW_VARS = ( + "MAX_FRAME_GAP", "CLEANUP_THRESHOLD", + "MAX_FRAME_GAP_SEC", "CLEANUP_THRESHOLD_SEC", +) + + +@pytest.fixture +def env(tmp_path, monkeypatch): + """.envの探索結果に左右されないよう、空の.envを明示的に読ませる。""" + for key, value in REQUIRED.items(): + monkeypatch.setenv(key, value) + for key in TIME_WINDOW_VARS: + monkeypatch.delenv(key, raising=False) + empty_env = tmp_path / ".env" + empty_env.write_text("", encoding="utf-8") + return lambda: Config.from_env(str(empty_env)) + + +def test_既定値は旧既定の30fps換算と一致する(env): + """MAX_FRAME_GAP=90 / CLEANUP_THRESHOLD=150 は30fpsで3秒 / 5秒だった。""" + config = env() + assert config.max_frame_gap_sec == 3.0 + assert config.cleanup_threshold_sec == 5.0 + + +def test_秒指定を読み込む(env, monkeypatch): + monkeypatch.setenv("MAX_FRAME_GAP_SEC", "2.5") + monkeypatch.setenv("CLEANUP_THRESHOLD_SEC", "7.5") + config = env() + assert config.max_frame_gap_sec == 2.5 + assert config.cleanup_threshold_sec == 7.5 + + +@pytest.mark.parametrize( + "old_name, new_name", + [("MAX_FRAME_GAP", "MAX_FRAME_GAP_SEC"), + ("CLEANUP_THRESHOLD", "CLEANUP_THRESHOLD_SEC")], +) +def test_旧env_varが残っていたら移行を促して停止する(env, monkeypatch, old_name, new_name): + """フレーム数の設定を黙って無視すると、窓の長さが意図せず既定値へ戻る。""" + monkeypatch.setenv(old_name, "90") + with pytest.raises(ValueError, match=new_name): + env() + + +@pytest.mark.parametrize( + "old_name, new_name", + [("MAX_FRAME_GAP", "MAX_FRAME_GAP_SEC"), + ("CLEANUP_THRESHOLD", "CLEANUP_THRESHOLD_SEC")], +) +def test_新旧が併記されていれば新のみを使う(env, monkeypatch, old_name, new_name): + """移行途中の.envを許容する(新が書いてあれば旧は無視してよい)。""" + monkeypatch.setenv(old_name, "90") + monkeypatch.setenv(new_name, "4.0") + config = env() + assert getattr(config, new_name.lower()) == 4.0 From a5f4a5b9715c81690765fc42af30e46057a9c926 Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:57:00 +0900 Subject: [PATCH 54/57] =?UTF-8?q?fix:=20setup=5Flines.py=E3=81=8C=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E5=BA=A7=E6=A8=99=E4=BB=A5=E5=A4=96=E3=81=AE?= =?UTF-8?q?.env=E8=A8=AD=E5=AE=9A=E3=82=92=E7=A0=B4=E5=A3=8A=E3=81=97?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save_to_envが.envファイル全体をハードコードのテンプレートで上書きしていた。 GUIでラインを引き直すたびに、他のキーがテンプレートの値へ戻る。 実害が2つあった。 1. 起動できない.envが生成される。テンプレートは廃止済みのCLEANUP_THRESHOLD=150を 書き出すため、GUI実行後はConfig.from_envが移行エラーで停止する。77cb510で MAX_FRAME_GAPを秒基準へ移したとき、同じテンプレート内のCLEANUP_THRESHOLDを 見落としていた。 2. 比較条件が失われる。テンプレートはMODEL_PATHをfine-tunedモデル、 CONFIDENCE_THRESHOLDを0.3で書き出し、VEHICLE_CLASSESとIOU_THRESHOLDに至っては 項目自体が無い。ROI方式と条件を揃えるために手で入れた値が消える。 roi-counterのroi_config.pyと同じ契約へ揃える。書き込むのはライン座標の10キーだけで、 他のキー・コメント・並び順は保持する。 - LINE_ENV_KEYS: 書き込み対象を明示 - build_line_env_values(points): 5点をキーと値へ変換(純ロジック) - apply_line_values(existing, values): 既存本文へ反映(純ロジック)。既存キーは 行ごと差し替え、無いキーは末尾のブロックへ追記する - save_to_env: I/Oのみ。.envが無い場合は.env.templateを土台にする I/Oと純ロジックを分離したのはroi_config.pyと同じ理由で、cv2をモックせずに テストできるようにするため。 テスト: raspi/ 全体で198 passed(test_setup_lines_env.py 10件を追加)。 GUI実行後の.envでConfig.from_envが起動できること、比較条件が保持されること、 二重適用で重複しないことを確認する。 Co-Authored-By: Claude Opus 5 --- .../line_detection/line_setup/setup_lines.py | 105 +++++++++++------- .../tests/test_setup_lines_env.py | 87 +++++++++++++++ 2 files changed, 149 insertions(+), 43 deletions(-) create mode 100644 raspi/line_detection/tests/test_setup_lines_env.py diff --git a/raspi/line_detection/line_setup/setup_lines.py b/raspi/line_detection/line_setup/setup_lines.py index c477876c..769976c0 100755 --- a/raspi/line_detection/line_setup/setup_lines.py +++ b/raspi/line_detection/line_setup/setup_lines.py @@ -18,6 +18,51 @@ from typing import List, Tuple, Optional +LINE_ENV_KEYS = ( + "LINE1_START_X", "LINE1_START_Y", "LINE1_END_X", "LINE1_END_Y", + "LINE2_START_X", "LINE2_START_Y", "LINE2_END_X", "LINE2_END_Y", + "PARKING_REF_X", "PARKING_REF_Y", +) + + +def build_line_env_values(points: List[Tuple[int, int]]) -> dict: + """クリックした5点をenvのキーと値へ変換する。 + + 順序は Line1始点 / Line1終点 / Line2始点 / Line2終点 / 駐車場基準点。 + """ + if len(points) != 5: + raise ValueError(f"5点必要です: {len(points)}点") + flat = [c for p in points for c in p] + return dict(zip(LINE_ENV_KEYS, flat)) + + +def apply_line_values(existing: str, values: dict) -> str: + """既存の.env本文へライン値を反映した本文を返す。 + + 既存キーはその行だけを差し替え、無いキーは末尾のブロックへ追記する。 + コメント、並び順、他のキーはそのまま保つ。 + """ + remaining = dict(values) + lines = existing.splitlines() + for i, line in enumerate(lines): + stripped = line.lstrip() + if stripped.startswith("#") or "=" not in stripped: + continue + key = stripped.split("=", 1)[0].strip() + if key in remaining: + lines[i] = f"{key}={remaining.pop(key)}" + + if remaining: + if lines and lines[-1].strip(): + lines.append("") + lines.append("# ライン座標(line_setup/setup_lines.pyが書き込む)") + for key in LINE_ENV_KEYS: + if key in remaining: + lines.append(f"{key}={remaining[key]}") + + return "\n".join(lines) + "\n" + + class LineSetupGUI: """ライン座標設定GUI""" @@ -231,51 +276,25 @@ def run(self) -> bool: return True def save_to_env(self): - """設定を.envファイルに保存""" - # 環境変数の内容を作成 - env_content = f"""# プロジェクトパス -HOME_DIR={os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))} - -# YOLOモデル -MODEL_PATH=${{HOME_DIR}}/yolo_fine_tuning/runs/detect/train14/weights/best.pt -CONFIDENCE_THRESHOLD=0.3 - -# Line1設定(入口側ライン) -LINE1_START_X={self.points[0][0]} -LINE1_START_Y={self.points[0][1]} -LINE1_END_X={self.points[1][0]} -LINE1_END_Y={self.points[1][1]} - -# Line2設定(駐車場側ライン) -LINE2_START_X={self.points[2][0]} -LINE2_START_Y={self.points[2][1]} -LINE2_END_X={self.points[3][0]} -LINE2_END_Y={self.points[3][1]} - -# 駐車場基準点(駐車場側を定義) -PARKING_REF_X={self.points[4][0]} -PARKING_REF_Y={self.points[4][1]} - -# 検知パラメータ -MARGIN_PX=5.0 -ENDPOINT_MARGIN_PX=0.0 -MAX_FRAME_GAP_SEC=3.0 # 秒。動画のfpsからフレーム数へ変換する -CLEANUP_THRESHOLD=150 # 5秒@30fps - -# 処理方式: hybrid固定 -METHOD=hybrid - -# 出力設定 -SAVE_VIDEO=true -SAVE_LOGS=true -SHOW_DISPLAY=false -""" + """ライン座標だけを.envへ書き戻す。 + + 他のキー、コメント、並び順は保持する(roi-counterのroi_config.pyと + 同じ契約)。以前はファイル全体をテンプレートで上書きしており、 + 比較条件のために手で入れた値が消えていた。 + """ + values = build_line_env_values(self.points) + + if os.path.exists(self.env_path): + existing = open(self.env_path, encoding="utf-8").read() + else: + template = os.path.join(os.path.dirname(self.env_path), ".env.template") + existing = open(template, encoding="utf-8").read() if os.path.exists(template) else "" - # .envファイルに書き込み - with open(self.env_path, 'w', encoding='utf-8') as f: - f.write(env_content) + with open(self.env_path, "w", encoding="utf-8") as f: + f.write(apply_line_values(existing, values)) - print(f"\n✓ 設定を保存しました: {self.env_path}") + print(f"\n✓ ライン座標を保存しました: {self.env_path}") + print(" (他のキーは変更していません)") print("\n設定内容:") print(f" Line1 (入口側): {self.points[0]} → {self.points[1]}") print(f" Line2 (駐車場側): {self.points[2]} → {self.points[3]}") diff --git a/raspi/line_detection/tests/test_setup_lines_env.py b/raspi/line_detection/tests/test_setup_lines_env.py new file mode 100644 index 00000000..13dd0843 --- /dev/null +++ b/raspi/line_detection/tests/test_setup_lines_env.py @@ -0,0 +1,87 @@ +"""setup_lines.py が .env へ書き戻す際の契約テスト。 + +以前はファイル全体をハードコードのテンプレートで上書きしており、比較条件のために +手で入れた値(CONFIDENCE_THRESHOLD、VEHICLE_CLASSES など)が消えていた。さらに +テンプレートが廃止済みの CLEANUP_THRESHOLD を書き出すため、GUI実行後の .env では +Config.from_env が移行エラーで停止する状態だった。 + +roi-counter の roi_config.py と同じ契約(書き込むのは対象キーだけ、他は保持)に +揃えたことを確認する。 +""" +import pytest + +from line_setup.setup_lines import ( + LINE_ENV_KEYS, + apply_line_values, + build_line_env_values, +) + +POINTS = [(10, 20), (30, 40), (50, 60), (70, 80), (90, 100)] + + +def test_5点をライン座標のキーへ順に割り当てる(): + v = build_line_env_values(POINTS) + assert list(v) == list(LINE_ENV_KEYS) + assert v["LINE1_START_X"] == 10 and v["LINE1_START_Y"] == 20 + assert v["LINE2_END_X"] == 70 and v["LINE2_END_Y"] == 80 + assert v["PARKING_REF_X"] == 90 and v["PARKING_REF_Y"] == 100 + + +@pytest.mark.parametrize("n", [0, 4, 6]) +def test_5点でなければ拒否する(n): + with pytest.raises(ValueError, match="5点"): + build_line_env_values([(1, 1)] * n) + + +def test_既存キーは行ごと差し替える(): + before = "LINE1_START_X=999\nLINE1_START_Y=888\n" + after = apply_line_values(before, build_line_env_values(POINTS)) + assert "LINE1_START_X=10" in after + assert "LINE1_START_Y=20" in after + assert "999" not in after and "888" not in after + + +def test_他のキーとコメントを保持する(): + """比較条件のために手で入れた値が消えないこと。""" + before = ( + "# 比較条件(ROI方式と揃える)\n" + "MODEL_PATH=yolov8s.pt\n" + "CONFIDENCE_THRESHOLD=0.25\n" + "VEHICLE_CLASSES=2,7\n" + "IOU_THRESHOLD=0.7\n" + "CLEANUP_THRESHOLD_SEC=5.0\n" + ) + after = apply_line_values(before, build_line_env_values(POINTS)) + for kept in ("# 比較条件(ROI方式と揃える)", "MODEL_PATH=yolov8s.pt", + "CONFIDENCE_THRESHOLD=0.25", "VEHICLE_CLASSES=2,7", + "IOU_THRESHOLD=0.7", "CLEANUP_THRESHOLD_SEC=5.0"): + assert kept in after + + +def test_廃止済みのCLEANUP_THRESHOLDを書き出さない(): + """GUI実行後の.envでConfig.from_envが起動できること。""" + after = apply_line_values("", build_line_env_values(POINTS)) + assert "CLEANUP_THRESHOLD=" not in after + assert "MAX_FRAME_GAP=" not in after + + +def test_キーが無い場合は末尾へ追記する(): + after = apply_line_values("MODEL_PATH=yolov8s.pt\n", build_line_env_values(POINTS)) + assert "MODEL_PATH=yolov8s.pt" in after + for key in LINE_ENV_KEYS: + assert f"{key}=" in after + + +def test_空の入力でも全キーを書き出す(): + after = apply_line_values("", build_line_env_values(POINTS)) + for key in LINE_ENV_KEYS: + assert f"{key}=" in after + assert after.endswith("\n") + + +def test_二重適用しても重複しない(): + v = build_line_env_values(POINTS) + once = apply_line_values("MODEL_PATH=yolov8s.pt\n", v) + twice = apply_line_values(once, v) + for key in LINE_ENV_KEYS: + assert twice.count(f"{key}=") == 1 From dec14531dc8f6f04b4ca797d79710bbecd086a2a Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:58:42 +0900 Subject: [PATCH 55/57] =?UTF-8?q?fix:=20visualize=5Flines=5Fand=5Fvehicles?= =?UTF-8?q?.py=E3=81=B8--env=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit このツールは.envのパスをハードコードしており、画角ごとに設定を分けている場合に 確認できなかった。新画角用のnewcam.envを追加した時点で、ライン配置を目視する 手段が失われていた。 main.pyとline_setup/setup_lines.pyは既に--envを持つ。同じ流儀へ揃える。 引数解析をargparseへ移したが、位置引数の並び(video / output / start_frame / end_frame)はVERIFICATION.md 3章の記載と互換を保った。既存のコマンドはそのまま動く。 読み込んだ設定のパスを起動時に表示するようにした。どの画角の設定で確認しているかが 出力から分かる。 Co-Authored-By: Claude Opus 5 --- .../visualize_lines_and_vehicles.py | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/raspi/line_detection/visualize_lines_and_vehicles.py b/raspi/line_detection/visualize_lines_and_vehicles.py index 487cde6c..b32c3f08 100644 --- a/raspi/line_detection/visualize_lines_and_vehicles.py +++ b/raspi/line_detection/visualize_lines_and_vehicles.py @@ -238,26 +238,40 @@ def visualize(video_path: str, config: Config, output_path: str = None, start_fr if __name__ == "__main__": - if len(sys.argv) < 2: - print("使用方法: python visualize_lines_and_vehicles.py <動画パス> [出力動画パス] [開始フレーム] [終了フレーム]") - print("\n例:") - print(" python visualize_lines_and_vehicles.py data/inputs/IMG_2787.mp4") - print(" python visualize_lines_and_vehicles.py data/inputs/IMG_2787.mp4 debug_vis.mp4") - print(" python visualize_lines_and_vehicles.py data/inputs/IMG_2787.mp4 debug_vis.mp4 300 600") - sys.exit(1) - - video_path = sys.argv[1] - output_path = sys.argv[2] if len(sys.argv) > 2 else None - start_frame = int(sys.argv[3]) if len(sys.argv) > 3 else 0 - end_frame = int(sys.argv[4]) if len(sys.argv) > 4 else 300 + import argparse + + parser = argparse.ArgumentParser( + description="ライン位置と車両検出位置を可視化", + epilog=( + "例:\n" + " python visualize_lines_and_vehicles.py data/inputs/IMG_2787.MOV\n" + " python visualize_lines_and_vehicles.py data/inputs/IMG_2787.MOV debug_vis.mp4\n" + " python visualize_lines_and_vehicles.py data/inputs/IMG_2787.MOV debug_vis.mp4 300 600\n" + " python visualize_lines_and_vehicles.py data/inputs/1787011229.231516.mp4 " + "vis.mp4 0 300 --env newcam.env" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + # 位置引数の並びはVERIFICATION.mdの記載と互換を保つ。 + parser.add_argument("video", help="動画ファイルのパス") + parser.add_argument("output", nargs="?", default=None, help="出力動画パス(省略時は保存しない)") + parser.add_argument("start_frame", nargs="?", type=int, default=0, help="開始フレーム") + parser.add_argument("end_frame", nargs="?", type=int, default=300, help="終了フレーム") + parser.add_argument( + "--env", + default=None, + help=".envファイルのパス(既定: line_detection/.env)。" + "画角ごとに設定を分けている場合に指定する", + ) + args = parser.parse_args() - # 設定を読み込み - env_path = os.path.join(os.path.dirname(__file__), ".env") + env_path = args.env or os.path.join(os.path.dirname(__file__), ".env") if not os.path.exists(env_path): print(f"エラー: .envファイルが見つかりません: {env_path}") sys.exit(1) config = Config.from_env(env_path) config.validate() + print(f"設定: {env_path}") - visualize(video_path, config, output_path, start_frame, end_frame) + visualize(args.video, config, args.output, args.start_frame, args.end_frame) From 62365b1067e042f60818bbfaadb9352c0c08aabc Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:22:08 +0900 Subject: [PATCH 56/57] =?UTF-8?q?feat:=20=E8=A4=87=E6=95=B0=E5=8B=95?= =?UTF-8?q?=E7=94=BB=E3=82=92=E9=A0=86=E3=81=AB=E5=87=A6=E7=90=86=E3=81=97?= =?UTF-8?q?=E3=81=A6=E6=AF=94=E8=BC=83=E7=94=A8=E3=82=B5=E3=83=9E=E3=83=AA?= =?UTF-8?q?=E3=83=BC=E3=81=B8=E9=9B=86=E7=B4=84=E3=81=99=E3=82=8B=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=83=8A=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2ライン方式のmain.pyは1動画ずつしか扱わない。Gate 4の同条件計測では6動画を 回して結果を突き合わせる必要があるため、ROI方式のscripts/04_multi_video_mae.pyに 相当するものを用意する。 各runは独立したサブプロセスで走らせる。理由が2つある。 1. load_dotenvは既存の環境変数を上書きしない。同一プロセスで2つの.envを読むと 後から読んだほうが無視され、画角ごとの設定切り替えが効かない。実際に確認した。 2. YOLOモデルとトラッカーの状態をrun間で持ち越さない。ROI方式が prepare_model_for_runで行っている初期化を、プロセス分離で保証する。 閾値スイープは行わない。2ライン方式には探索対象のパラメータが無く、 04_multi_video_mae.pyの組み合わせループに相当するものが存在しないため。 実行前チェックを先に通す。動画・設定・GTの存在を全動画分まとめて確認してから 1本目を始める。1本目を数十分かけて処理した後に3本目のGTが無いと分かる、という 失敗を避けるため。 comparison_keyの突合を実行直後に行う。ROI方式の確定runのmanifestから動画ごとの comparison_keyを読み、一致しない動画があれば警告する。一致するrun同士でなければ 速度値を方式間で直接比較できない、というのがcomparison_keyの設計意図であり、 計測してから数日後に気付くのでは遅い。 出力はdata/outputs/{EXP_NAME}/{video_stem}/へ動画ごとに分け、summary.csvへ 台数・誤差・confidence内訳・速度統計・run識別子を集約する。 Co-Authored-By: Claude Opus 5 --- raspi/line_detection/run_multi_video.py | 229 ++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 raspi/line_detection/run_multi_video.py diff --git a/raspi/line_detection/run_multi_video.py b/raspi/line_detection/run_multi_video.py new file mode 100644 index 00000000..eacb7cfd --- /dev/null +++ b/raspi/line_detection/run_multi_video.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""複数動画をmain.pyで順に処理し、方式比較用のサマリーへ集約する。 + +ROI方式の scripts/04_multi_video_mae.py に相当する。2ライン方式のmain.pyは +1動画ずつしか扱わないため、Gate 4の同条件計測には動画ごとの実行と結果の集約が要る。 + +**各runは独立したサブプロセスで走らせる。** 理由が2つある。 + +1. `load_dotenv` は既存の環境変数を上書きしない。同一プロセスで2つの.envを + 読むと後から読んだほうが無視されるため、画角ごとに設定を切り替えられない。 +2. YOLOモデルとトラッカーの状態をrun間で持ち越さない。ROI方式が + prepare_model_for_run で行っている初期化を、プロセス分離で保証する。 + +閾値スイープは行わない。2ライン方式には探索対象のパラメータが無いため、 +04_multi_video_mae.py の組み合わせループに相当するものは存在しない。 + +出力: + data/outputs/{EXP_NAME}/ + ├── {video_stem}/ + │ ├── logs/events_.json # main.pyが書く + │ └── manifests/.json # 同上 + └── summary.csv # 本スクリプトが集約する +""" +import csv +import glob +import json +import os +import subprocess +import sys +from pathlib import Path + +# ── パラメータ ────────────────────────────────────────────────────────────── +# 画角が異なる動画は設定ファイルを分ける。newcam.envは新画角5本、img2787.envは +# 旧画角。比較条件(model/conf/iou/classes/imgsz/tracker/device/warmup)は両者で +# 同一にしてあり、ROI方式の確定runとcomparison_keyが一致するよう揃えてある。 +VIDEOS = [ + ("data/inputs/1787008160.558032.mp4", "newcam.env"), + ("data/inputs/1787009706.719727.mp4", "newcam.env"), + ("data/inputs/1787011229.231516.mp4", "newcam.env"), + ("data/inputs/1787012751.179971.mp4", "newcam.env"), + ("data/inputs/1787014266.421887.mp4", "newcam.env"), + ("data/inputs/IMG_2787.MOV", "img2787.env"), +] + +# GTはROI方式と同一のファイルを見る。両方式が同じ正解を参照することが比較の前提。 +GT_DIR = os.getenv("GT_DIR", "../roi-counter/data/inputs/configs") +EXP_NAME = os.getenv("EXP_NAME", "gate4_2line") + +# comparison_keyの突合先。空文字を渡すと突合を行わない。 +ROI_RUN_DIR = os.getenv( + "ROI_RUN_DIR", + "../roi-counter/data/outputs/exp_adopted_final_gtfix/mae_20260828_062140", +) +# ──────────────────────────────────────────────────────────────────────────── + +SUMMARY_COLUMNS = ( + "video", "count_in", "gt_in", "count_out", "gt_out", "count_error", + "high_confidence_events", "normal_confidence_events", + "core_ms_p95", "end_to_end_ms_p95", "deadline_miss_rate", + "wandb_run_id", "execution_id", "condition_key", "comparison_key", +) + + +def gt_path_for(video: str) -> Path: + """動画パスから対応するGTファイルのパスを導く。""" + return Path(GT_DIR) / f"{Path(video).stem}_gt.json" + + +def check_inputs() -> None: + """動画・設定・GTの存在を先に確認する。 + + 1本目を数十分かけて処理した後に3本目のGTが無いと分かる、という失敗を避ける。 + """ + missing = [] + for video, env in VIDEOS: + if not Path(video).exists(): + missing.append(f"動画がありません: {video}") + if not Path(env).exists(): + missing.append(f"設定がありません: {env}") + gt = gt_path_for(video) + if not gt.exists(): + missing.append(f"GTがありません: {gt}") + if missing: + raise SystemExit("[ERROR] 実行前チェックに失敗しました:\n " + "\n ".join(missing)) + + +def run_one(video: str, env: str, out_dir: Path) -> int: + """main.pyを1動画ぶん実行する。戻り値は終了コード。""" + cmd = [ + sys.executable, "main.py", + "--input", video, + "--env", env, + "--gt", str(gt_path_for(video)), + "--output", str(out_dir), + ] + print(f"\n{'=' * 70}\n実行: {Path(video).name} (env={env})\n{'=' * 70}", flush=True) + return subprocess.run(cmd).returncode + + +def latest_json(pattern: str) -> dict | None: + paths = sorted(glob.glob(pattern)) + if not paths: + return None + return json.loads(Path(paths[-1]).read_text(encoding="utf-8")) + + +def collect(video: str, out_dir: Path) -> dict: + """1runぶんの出力からサマリー行を組み立てる。""" + events = latest_json(str(out_dir / "logs" / "events_*.json")) + manifest = latest_json(str(out_dir / "manifests" / "*.json")) + if events is None: + return {"video": Path(video).name, "count_error": "収集失敗"} + + summary = events.get("summary", {}) + accuracy = events.get("accuracy", {}) + timing = events.get("timing", {}) + config = (manifest or {}).get("config", {}) + return { + "video": Path(video).name, + "count_in": summary.get("total_in"), + "gt_in": accuracy.get("gt_in"), + "count_out": summary.get("total_out"), + "gt_out": accuracy.get("gt_out"), + "count_error": accuracy.get("count_error"), + "high_confidence_events": summary.get("high_confidence_events"), + "normal_confidence_events": summary.get("normal_confidence_events"), + "core_ms_p95": timing.get("core_ms_p95"), + "end_to_end_ms_p95": timing.get("end_to_end_ms_p95"), + "deadline_miss_rate": timing.get("deadline_miss_rate"), + "wandb_run_id": events.get("wandb_run_id"), + "execution_id": events.get("execution_id"), + "condition_key": events.get("condition_key"), + "comparison_key": config.get("comparison_key"), + } + + +def roi_comparison_keys() -> dict: + """ROI方式の確定runから、動画名ごとのcomparison_keyを読む。""" + keys = {} + for path in glob.glob(str(Path(ROI_RUN_DIR) / "manifests" / "*.json")): + config = json.loads(Path(path).read_text(encoding="utf-8")).get("config", {}) + source = config.get("input_source") + if source and config.get("comparison_key"): + keys[Path(source).name] = config["comparison_key"] + return keys + + +def print_summary(rows: list, summary_path: Path) -> None: + print(f"\n{'=' * 74}\n集約結果\n{'=' * 74}") + print(f"{'video':<26}{'IN':>10}{'OUT':>10}{'err':>6}{'high':>6}{'normal':>8}{'p95ms':>9}") + total_error = 0 + for row in rows: + err = row.get("count_error") + if isinstance(err, int): + total_error += err + p95 = row.get("core_ms_p95") + in_col = f"{row.get('count_in')}/{row.get('gt_in')}" + out_col = f"{row.get('count_out')}/{row.get('gt_out')}" + p95_col = f"{p95:.1f}" if isinstance(p95, (int, float)) else "-" + print( + f"{row['video']:<26}{in_col:>10}{out_col:>10}{str(err):>6}" + f"{str(row.get('high_confidence_events')):>6}" + f"{str(row.get('normal_confidence_events')):>8}{p95_col:>9}" + ) + print(f"\n合計誤差: {total_error} 出力: {summary_path}") + + +def print_comparison_check(rows: list) -> None: + """ROI方式のrunとcomparison_keyが一致するかを動画ごとに突合する。 + + 一致するrun同士でなければ速度値を方式間で直接比較できない、というのが + comparison_keyの設計意図であるため、計測の直後に確認する。 + """ + roi_keys = roi_comparison_keys() if ROI_RUN_DIR else {} + if not roi_keys: + print("\n[INFO] ROI側のmanifestが無いため、comparison_keyの突合は行いません。") + return + + print(f"\ncomparison_key の突合(対象: {ROI_RUN_DIR})") + mismatched = 0 + for row in rows: + roi_key = roi_keys.get(row["video"]) + if roi_key is None: + verdict = "ROI側に該当なし" + elif roi_key == row.get("comparison_key"): + verdict = "一致" + else: + verdict = "不一致" + mismatched += 1 + print(f" {row['video']:<26} {verdict}") + if mismatched: + print(f"\n[WARN] {mismatched}件でcomparison_keyが一致しません。" + "速度値を方式間で直接比較できません。") + + +def main() -> int: + check_inputs() + base = Path("data/outputs") / EXP_NAME + base.mkdir(parents=True, exist_ok=True) + print(f"動画数: {len(VIDEOS)} 出力先: {base}") + + rows, failed = [], [] + for video, env in VIDEOS: + out_dir = base / Path(video).stem + code = run_one(video, env, out_dir) + if code != 0: + failed.append(video) + print(f"[ERROR] 異常終了(code={code}): {video}", flush=True) + rows.append(collect(video, out_dir)) + + summary_path = base / "summary.csv" + with summary_path.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=list(SUMMARY_COLUMNS)) + writer.writeheader() + for row in rows: + writer.writerow({k: row.get(k) for k in SUMMARY_COLUMNS}) + + print_summary(rows, summary_path) + print_comparison_check(rows) + + if failed: + print(f"\n[ERROR] {len(failed)}本が異常終了: " + + ", ".join(Path(v).name for v in failed)) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d558ef20993c0b5cdb7206d6a930600c42be5f4a Mon Sep 17 00:00:00 2001 From: YushinOgasawara <168957325+ucn-yushin@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:35:18 +0900 Subject: [PATCH 57/57] =?UTF-8?q?feat:=20=E5=8B=95=E7=94=BB=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=ABROI=E6=96=B9=E5=BC=8F=E3=81=A82=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E6=96=B9=E5=BC=8F=E3=82=92=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E3=81=99=E3=82=8B=E3=82=B9=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=97=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- raspi/line_detection/run_gate4_alternating.py | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 raspi/line_detection/run_gate4_alternating.py diff --git a/raspi/line_detection/run_gate4_alternating.py b/raspi/line_detection/run_gate4_alternating.py new file mode 100644 index 00000000..72fe4676 --- /dev/null +++ b/raspi/line_detection/run_gate4_alternating.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""ROI方式と2ライン方式を動画ごとに交互実行し、同一セッション内の相対速度を測る。 + +docs.local/gate4-speed-comparison-caveat.html の結論(開発機での速度比較は +機体の熱状態に支配され、別々の時間帯に測った2つのrun同士では比較できない) +を受けた案1の実装。動画ごとにROI→2ライン→ROI→…と交互に呼ぶことで、熱状態の +上がり方を両方式へ均等にかけ、このrun内でのcore_ms_p95の比を相対比較として +読めるようにする。 + +ROI方式のコードは feat/mike/89-bbox-analysis-within-roi にしかなく、本ブランチの +worktreeには存在しない。そのため別ディレクトリにROI方式用のgit worktreeを用意し +(ROI_ROOT)、そちらのscripts/04_multi_video_mae.pyをサブプロセスから動画1本ぶんだけ +呼び出す(scripts/_gate4_single_video_runner.py、worktree内のみに存在するローカル +専用ブートストラップ。04_multi_video_mae.py自体は無変更)。2ライン方式はmain.pyを +run_multi_video.pyと同じ流儀でサブプロセス実行する。 + +各方式を独立サブプロセスにする理由はrun_multi_video.pyと同じ +(.envの上書き問題/YOLOモデルとトラッカーの状態をrun間で持ち越さないため)。 + +出力: + data/outputs/{EXP_NAME}/ + ├── {video_stem}/ # 2ライン方式(main.pyが書く) + ├── timeline.csv # 交互実行の実施順・開始/終了時刻 + └── summary_alternating.csv # 方式別の速度・精度を並べた集約 +""" +import csv +import glob +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import pandas as pd + +# ── パラメータ ────────────────────────────────────────────────────────────── +VIDEOS = [ + ("data/inputs/1787008160.558032.mp4", "newcam.env"), + ("data/inputs/1787009706.719727.mp4", "newcam.env"), + ("data/inputs/1787011229.231516.mp4", "newcam.env"), + ("data/inputs/1787012751.179971.mp4", "newcam.env"), + ("data/inputs/1787014266.421887.mp4", "newcam.env"), + ("data/inputs/IMG_2787.MOV", "img2787.env"), +] + +GT_DIR = os.getenv("GT_DIR", "../roi-counter/data/inputs/configs") +EXP_NAME = os.getenv("EXP_NAME", "gate4_alt") + +# ROI方式のコードが実際に存在するworktree(本ブランチには無い。docs.local/ +# gate4-speed-comparison-caveat.htmlの案1採用にあたり `git worktree add` で作成)。 +ROI_ROOT = Path(os.getenv( + "ROI_ROOT", "/Users/ycn/Workspace/NUTMEG/tracking-parking-roi-mike", +)) +ROI_DIR = ROI_ROOT / "raspi" / "roi-counter" +ROI_BOOTSTRAP = ROI_DIR / "scripts" / "_gate4_single_video_runner.py" + +# ROI方式の確定run(exp_adopted_final_gtfix)と同じ採用値。 +S_LOW = os.getenv("S_LOW", "0.26") +S_HIGH = os.getenv("S_HIGH", "0.32") +# ──────────────────────────────────────────────────────────────────────────── + +TIMELINE_COLUMNS = ("seq", "method", "video", "started_at", "ended_at", "duration_sec", "returncode") + +SUMMARY_COLUMNS = ( + "video", "roi_count_in", "roi_gt_in", "roi_count_out", "roi_gt_out", "roi_count_error", + "roi_core_ms_p95", "roi_comparison_key", + "line_count_in", "line_gt_in", "line_count_out", "line_gt_out", "line_count_error", + "line_core_ms_p95", "line_comparison_key", + "speed_ratio_line_over_roi", +) + + +def gt_path_for(video: str) -> Path: + return Path(GT_DIR) / f"{Path(video).stem}_gt.json" + + +def check_inputs() -> None: + missing = [] + for video, env in VIDEOS: + if not Path(video).exists(): + missing.append(f"動画がありません: {video}") + if not Path(env).exists(): + missing.append(f"設定がありません: {env}") + if not gt_path_for(video).exists(): + missing.append(f"GTがありません: {gt_path_for(video)}") + if not ROI_BOOTSTRAP.exists(): + missing.append( + f"ROI方式のブートストラップがありません: {ROI_BOOTSTRAP}\n" + " git worktree add でfeat/mike/89-bbox-analysis-within-roiを展開し、" + "raspi/roi-counter/scripts/_gate4_single_video_runner.py を用意してください。" + ) + if missing: + raise SystemExit("[ERROR] 実行前チェックに失敗しました:\n " + "\n ".join(missing)) + + +def now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat() + + +def run_logged(cmd: list, *, cwd: Path, env: dict, method: str, video: str, seq: int, + timeline_rows: list) -> int: + print(f"\n{'=' * 70}\n[{seq}] {method}: {Path(video).name}\n{'=' * 70}", flush=True) + started = now_iso() + t0 = time.monotonic() + proc = subprocess.run(cmd, cwd=str(cwd), env=env) + duration = time.monotonic() - t0 + ended = now_iso() + timeline_rows.append({ + "seq": seq, "method": method, "video": Path(video).name, + "started_at": started, "ended_at": ended, + "duration_sec": round(duration, 1), "returncode": proc.returncode, + }) + return proc.returncode + + +def run_roi_single(video: str, seq: int, timeline_rows: list) -> dict: + roi_gt_config = (Path.cwd() / gt_path_for(video)).resolve() + before = set(glob.glob(str(ROI_DIR / "data" / "outputs" / EXP_NAME / "mae_*"))) + + env = os.environ.copy() + env.update({ + "S_LOW_LIST": S_LOW, + "S_HIGH_LIST": S_HIGH, + "EXP_NAME": EXP_NAME, + "USE_WANDB": "true", + "WANDB_MODE": "offline", + "WANDB_PROJECT": "tracking-parking", + "WANDB_DIR": "data/outputs", + }) + env.pop("YOLO_DEVICE", None) # 確定runと同じくUltralyticsの自動選択に委ねる + + cmd = [sys.executable, str(ROI_BOOTSTRAP), "--gt-config", str(roi_gt_config)] + code = run_logged(cmd, cwd=ROI_DIR, env=env, method="ROI", video=video, seq=seq, + timeline_rows=timeline_rows) + if code != 0: + print(f"[ERROR] ROI方式が異常終了(code={code}): {video}", flush=True) + return {"video": Path(video).name} + + after = set(glob.glob(str(ROI_DIR / "data" / "outputs" / EXP_NAME / "mae_*"))) + new_dirs = sorted(after - before) + if not new_dirs: + print(f"[ERROR] ROI方式の出力ディレクトリが見つかりません: {video}", flush=True) + return {"video": Path(video).name} + mae_dir = Path(new_dirs[-1]) + return collect_roi(video, mae_dir) + + +def collect_roi(video: str, mae_dir: Path) -> dict: + manifest_paths = sorted(glob.glob(str(mae_dir / "manifests" / "*.json"))) + if not manifest_paths: + return {"video": Path(video).name, "roi_count_error": "収集失敗"} + manifest = json.loads(Path(manifest_paths[-1]).read_text(encoding="utf-8")) + config = manifest.get("config", {}) + + results_csv = mae_dir / "results.csv" + row = {} + if results_csv.exists(): + df = pd.read_csv(results_csv) + if not df.empty: + row = df.iloc[-1].to_dict() + + core_ms_p95 = None + timing_path = mae_dir / "gate4_timing_summary.json" + if timing_path.exists(): + timing_summary = json.loads(timing_path.read_text(encoding="utf-8")) + core_ms_p95 = timing_summary.get("core_ms_p95") + + return { + "video": Path(video).name, + "roi_count_in": row.get("count_in"), + "roi_gt_in": row.get("gt_in"), + "roi_count_out": row.get("count_out"), + "roi_gt_out": row.get("gt_out"), + "roi_count_error": row.get("count_error"), + "roi_core_ms_p95": core_ms_p95, + "roi_comparison_key": config.get("comparison_key"), + } + + +def run_line_single(video: str, env_file: str, seq: int, timeline_rows: list) -> dict: + out_dir = Path("data/outputs") / EXP_NAME / Path(video).stem + cmd = [ + sys.executable, "main.py", + "--input", video, + "--env", env_file, + "--gt", str(gt_path_for(video)), + "--output", str(out_dir), + ] + code = run_logged(cmd, cwd=Path.cwd(), env=os.environ.copy(), method="2ライン", + video=video, seq=seq, timeline_rows=timeline_rows) + if code != 0: + print(f"[ERROR] 2ライン方式が異常終了(code={code}): {video}", flush=True) + return collect_line(video, out_dir) + + +def latest_json(pattern: str) -> dict | None: + paths = sorted(glob.glob(pattern)) + if not paths: + return None + return json.loads(Path(paths[-1]).read_text(encoding="utf-8")) + + +def collect_line(video: str, out_dir: Path) -> dict: + events = latest_json(str(out_dir / "logs" / "events_*.json")) + manifest = latest_json(str(out_dir / "manifests" / "*.json")) + if events is None: + return {"video": Path(video).name, "line_count_error": "収集失敗"} + + summary = events.get("summary", {}) + accuracy = events.get("accuracy", {}) + timing = events.get("timing", {}) + config = (manifest or {}).get("config", {}) + return { + "video": Path(video).name, + "line_count_in": summary.get("total_in"), + "line_gt_in": accuracy.get("gt_in"), + "line_count_out": summary.get("total_out"), + "line_gt_out": accuracy.get("gt_out"), + "line_count_error": accuracy.get("count_error"), + "line_core_ms_p95": timing.get("core_ms_p95"), + "line_comparison_key": config.get("comparison_key"), + } + + +def print_summary(rows: list) -> None: + print(f"\n{'=' * 90}\n交互実行 集約結果\n{'=' * 90}") + header = f"{'video':<24}{'ROI p95':>10}{'2line p95':>11}{'比(2line/ROI)':>16}{'ROI err':>9}{'2line err':>11}" + print(header) + for row in rows: + roi_p95 = row.get("roi_core_ms_p95") + line_p95 = row.get("line_core_ms_p95") + ratio = row.get("speed_ratio_line_over_roi") + roi_p95_s = f"{roi_p95:.1f}" if isinstance(roi_p95, (int, float)) else "-" + line_p95_s = f"{line_p95:.1f}" if isinstance(line_p95, (int, float)) else "-" + ratio_s = f"{ratio:.2f}x" if isinstance(ratio, (int, float)) else "-" + print( + f"{row['video']:<24}{roi_p95_s:>10}{line_p95_s:>11}{ratio_s:>16}" + f"{str(row.get('roi_count_error')):>9}{str(row.get('line_count_error')):>11}" + ) + + +def main() -> int: + check_inputs() + base = Path("data/outputs") / EXP_NAME + base.mkdir(parents=True, exist_ok=True) + (ROI_DIR / "data" / "outputs" / EXP_NAME).mkdir(parents=True, exist_ok=True) + print(f"動画数: {len(VIDEOS)} 出力先(2ライン): {base} 出力先(ROI): " + f"{ROI_DIR / 'data' / 'outputs' / EXP_NAME}") + + timeline_rows: list = [] + rows: list = [] + seq = 0 + for video, env_file in VIDEOS: + seq += 1 + roi_result = run_roi_single(video, seq, timeline_rows) + seq += 1 + line_result = run_line_single(video, env_file, seq, timeline_rows) + + merged = {**roi_result, **{k: v for k, v in line_result.items() if k != "video"}} + roi_p95 = merged.get("roi_core_ms_p95") + line_p95 = merged.get("line_core_ms_p95") + if isinstance(roi_p95, (int, float)) and isinstance(line_p95, (int, float)) and roi_p95 > 0: + merged["speed_ratio_line_over_roi"] = line_p95 / roi_p95 + rows.append(merged) + + # 動画ごとに逐次書き込み(数時間かかるrunなので、失敗時も途中結果を失わない)。 + timeline_path = base / "timeline.csv" + with timeline_path.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=list(TIMELINE_COLUMNS)) + writer.writeheader() + writer.writerows(timeline_rows) + + summary_path = base / "summary_alternating.csv" + with summary_path.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=list(SUMMARY_COLUMNS)) + writer.writeheader() + for r in rows: + writer.writerow({k: r.get(k) for k in SUMMARY_COLUMNS}) + + print_summary(rows) + print(f"\n出力: {base / 'summary_alternating.csv'} {base / 'timeline.csv'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())