Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
90a27ce
update: .gitignoreにdataディレクトリとAI駆動開発のためのディレクトリとファイルの追加
kei-asami Jun 21, 2026
c7023a7
update: .gitignoreにAIエージェント作業用のディレクトリを追加
kei-asami Jun 21, 2026
e5446cb
update: uv環境にライブラリ追加
kei-asami Jun 21, 2026
1c2ce1e
add: src/roi.py - ROI判定モジュールを追加
kei-asami Jun 21, 2026
06518ff
add: src/progress.py - 進行度s計算モジュールを追加
kei-asami Jun 21, 2026
be974d7
add: src/tracker.py - 車両状態データクラスを追加
kei-asami Jun 21, 2026
bbfa5fe
add: src/counter.py - 入出庫カウント状態機械を追加
kei-asami Jun 21, 2026
8f88afc
add: src/visualizer.py - フレーム描画モジュールを追加
kei-asami Jun 21, 2026
cd3aefe
add: srcに__init__.py追加
kei-asami Jun 21, 2026
48a24bf
add: scripts/00_convert_fps.py - FPS変換スクリプトを追加
kei-asami Jun 21, 2026
3a69989
add: scripts/01_show_roi.py - ROI確認スクリプトを追加
kei-asami Jun 21, 2026
4a6adf6
add: scripts/02_run_analysis.py - 分析用実行スクリプトを追加
kei-asami Jun 21, 2026
c0ed237
add: scripts/03_sweep_params.py - 閾値スイープスクリプトを追加
kei-asami Jun 21, 2026
9dad75e
add: scripts/04_multi_video_mae.py - 複数動画MAEスクリプトを追加
kei-asami Jun 21, 2026
2338792
add: analysis/01_visualize_threshold_sweep.py - 閾値感度可視化を追加
kei-asami Jun 21, 2026
2e7574e
add: main.py - 本番実行スクリプトを追加
kei-asami Jun 21, 2026
c932436
fix: 04_multi_video_mae.pyのs閾値のパラメータ指定方法の変更
kei-asami Jun 21, 2026
7ccd8c6
add: roi-counterのREADME追加
kei-asami Jun 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,17 @@ __pycache__/
.env
raspi/result/prediction_visual.png
*.pt
.venv/
.venv/

# data directory
data/
data/*

# files and directories for AI agents
CLAUDE.md
docs/
docs/*
work-agents/
work-agents/*
.claude/
.claude/*
15 changes: 14 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,17 @@ version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
dependencies = [
"lapx>=0.9.4",
"matplotlib>=3.11.0",
"numpy>=2.4.6",
"opencv-python>=4.13.0.92",
"pandas>=3.0.3",
"seaborn>=0.13.2",
"ultralytics>=8.4.72",
]

[dependency-groups]
dev = [
"pytest>=9.1.1",
]
120 changes: 120 additions & 0 deletions raspi/roi-counter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# roi-counter

ROI内を通過する車両の進行度 `s` を用いて入庫・出庫を判定するカウントシステム.

## ディレクトリ構造

```
roi-counter/
├── src/ # コアモジュール
│ ├── roi.py # ROI判定・y範囲取得
│ ├── progress.py # 進行度 s の計算
│ ├── tracker.py # 車両状態データクラス
│ ├── counter.py # 状態機械・入出庫カウント
│ └── visualizer.py # フレーム描画
├── scripts/
│ ├── 00_convert_fps.py # FPS変換
│ ├── 01_show_roi.py # ROI確認(静止フレーム)
│ ├── 02_run_analysis.py # 1動画の詳細分析
│ ├── 03_sweep_params.py # 1動画 × 閾値スイープ
│ └── 04_multi_video_mae.py # 複数動画 × 閾値 → MAE
├── analysis/
│ └── 01_visualize_threshold_sweep.py # スイープ結果の可視化
├── tests/ # ユニットテスト
└── main.py # 本番推論
```

## パラメータ(各スクリプト先頭でハードコード)

| パラメータ | 説明 |
|---|---|
| `VIDEO_SOURCE` | 動画ファイルパスまたはカメラインデックス(`0` 等) |
| `ROI_POINTS` | ROIの4頂点(画素座標,左上から時計回り) |
| `S_LOW` | 入口側バンドの上限(`s < S_LOW` → 入口側) |
| `S_HIGH` | 奥側バンドの下限(`s > S_HIGH` → 奥側) |
| `VEHICLE_CLASSES` | 検出対象クラス(COCO: `2`=car, `7`=truck) |

## scripts/ 各スクリプトの用途と入力

### 00_convert_fps.py
指定動画のFPSを落として同ディレクトリに `{stem}_fixed{ext}` で出力する.

**入力**: `VIDEO_SOURCE`(動画ファイルパス)

---

### 01_show_roi.py
動画の指定秒数地点のフレームにグリッド・ROI・バンドラインを描画して確認する.

**入力**: `VIDEO_SOURCE`,`ROI_POINTS`,`S_LOW`,`S_HIGH`

**出力**: `data/outputs/roi_check.png`

---

### 02_run_analysis.py
1動画を処理し,車両ごとの軌跡・フレームごとの処理時間・アノテーション動画を出力する.

**入力**: `VIDEO_SOURCE`,`ROI_POINTS`,`S_LOW`,`S_HIGH`

**出力**: `data/outputs/{EXP_NAME}/{stem}_{timestamp}/`
```
├── result.json # カウント結果・処理時間サマリー
├── vehicles.csv # track_id ごとの s_history・状態
├── frames.csv # フレームごとの処理時間
└── annotated.mp4 # 可視化済み動画
```

---

### 03_sweep_params.py
1動画に対して `S_LOW_RANGE × S_HIGH_RANGE` の全組み合わせを実行し,Count Error を記録する.

**入力**:
- `VIDEO_SOURCE`(動画ファイルパス)
- `GT_PATH`(グランドトゥルース JSON)またはファイル名から自動導出(`{stem}_gt.json`)
- `ROI_POINTS`,`S_LOW_RANGE`,`S_HIGH_RANGE`

**GT JSONフォーマット**(`data/inputs/{stem}_gt.json`):
```json
{"in": 29, "out": 2}
```

**出力**: `data/outputs/{EXP_NAME}/sweep_{timestamp}/results.csv`
```
s_low, s_high, count_in, count_out, gt_in, gt_out, count_error, elapsed_ms, mean_frame_ms, max_frame_ms
```

---

### 04_multi_video_mae.py
複数動画にわたって `S_LOW_LIST × S_HIGH_LIST` の組み合わせを実行し,MAEを算出する.

**入力**: `GT_DIR` 配下の設定 JSON(1動画につき1ファイル)

**設定 JSONフォーマット**(`data/inputs/configs/{name}.json`):
```json
{
"video": "data/movies/IMG_2788_fixed.MOV",
"roi": [[630, 770], [1270, 770], [1530, 1000], [390, 1000]],
"in": 29,
"out": 2
}
```

**出力**: `data/outputs/{EXP_NAME}/mae_{timestamp}/`
```
├── results.csv # 動画 × パラメータごとの詳細
└── mae_summary.csv # パラメータごとのMAEサマリー
```

---

## analysis/

### 01_visualize_threshold_sweep.py
`03_sweep_params.py` の `results.csv` を読み込んでヒートマップとラインプロットを生成する.

**入力**: `SWEEP_CSV`(`results.csv` のパス)

**出力**: 同ディレクトリに `heatmap_count_error.png`,`line_s_low.png`,`line_s_high.png`,`heatmap_elapsed_ms.png`
76 changes: 76 additions & 0 deletions raspi/roi-counter/analysis/01_visualize_threshold_sweep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import sys
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

# ── params ──────────────────────────────────────────────────────────────────
SWEEP_CSV = "data/outputs/exp1_2788_fixed/sweep_20260621_191909/results.csv"
# ────────────────────────────────────────────────────────────────────────────


def main() -> None:
csv_path = Path(SWEEP_CSV)
if not csv_path.exists():
print(f"[ERROR] file not found: {csv_path}")
sys.exit(1)

df = pd.read_csv(csv_path)
out_dir = csv_path.parent

if df["count_error"].isna().all():
print("[WARN] count_error is all NaN (no GT file). Visualizing elapsed_ms only.")

# ── heatmap: Count Error ───────────────────────────────────────────────
if not df["count_error"].isna().all():
pivot = df.pivot(index="s_low", columns="s_high", values="count_error")
fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(pivot, annot=True, fmt=".0f", cmap="YlOrRd_r", ax=ax)
ax.set_title("Count Error (lower is better)")
ax.set_xlabel("S_HIGH")
ax.set_ylabel("S_LOW")
fig.tight_layout()
fig.savefig(out_dir / "heatmap_count_error.png", dpi=150)
plt.close(fig)
print(f"saved: {out_dir / 'heatmap_count_error.png'}")

# ── line: S_LOW ───────────────────────────────────────────────────
mean_by_slow = df.groupby("s_low")["count_error"].mean()
fig, ax = plt.subplots()
ax.plot(mean_by_slow.index, mean_by_slow.values, marker="o")
ax.set_title("S_LOW vs Count Error (mean over S_HIGH)")
ax.set_xlabel("S_LOW")
ax.set_ylabel("Count Error (mean)")
fig.tight_layout()
fig.savefig(out_dir / "line_s_low.png", dpi=150)
plt.close(fig)
print(f"saved: {out_dir / 'line_s_low.png'}")

# ── line: S_HIGH ──────────────────────────────────────────────────
mean_by_shigh = df.groupby("s_high")["count_error"].mean()
fig, ax = plt.subplots()
ax.plot(mean_by_shigh.index, mean_by_shigh.values, marker="o")
ax.set_title("S_HIGH vs Count Error (mean over S_LOW)")
ax.set_xlabel("S_HIGH")
ax.set_ylabel("Count Error (mean)")
fig.tight_layout()
fig.savefig(out_dir / "line_s_high.png", dpi=150)
plt.close(fig)
print(f"saved: {out_dir / 'line_s_high.png'}")

# ── heatmap: elapsed_ms ────────────────────────────────────────────────
pivot_t = df.pivot(index="s_low", columns="s_high", values="elapsed_ms")
fig, ax = plt.subplots(figsize=(8, 6))
sns.heatmap(pivot_t, annot=True, fmt=".0f", cmap="Blues", ax=ax)
ax.set_title("elapsed_ms")
ax.set_xlabel("S_HIGH")
ax.set_ylabel("S_LOW")
fig.tight_layout()
fig.savefig(out_dir / "heatmap_elapsed_ms.png", dpi=150)
plt.close(fig)
print(f"saved: {out_dir / 'heatmap_elapsed_ms.png'}")


if __name__ == "__main__":
main()
71 changes: 71 additions & 0 deletions raspi/roi-counter/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))

import cv2
from ultralytics import YOLO

from src.counter import Counter
from src.roi import get_roi_y_range, is_in_roi
from src.progress import calc_s
from src.visualizer import draw_band_lines, draw_bbox_with_info, draw_counts, draw_roi

# ── パラメータ ──────────────────────────────────────────────────────────────
VIDEO_SOURCE: str | int = "data/inputs/xxxx.mov"

ROI_POINTS = [
(100, 100),
(300, 100),
(300, 300),
(100, 300),
]
S_LOW = 0.25
S_HIGH = 0.75
VEHICLE_CLASSES = [2, 7] # COCO: 2=car, 7=truck
# ────────────────────────────────────────────────────────────────────────────


def main() -> None:
model = YOLO("yolov8s.pt")
cap = cv2.VideoCapture(VIDEO_SOURCE)
if not cap.isOpened():
print(f"[ERROR] 入力を開けません: {VIDEO_SOURCE}")
sys.exit(1)

counter = Counter(S_LOW, S_HIGH)
y_min, y_max = get_roi_y_range(ROI_POINTS)

while cap.isOpened():
ret, frame = cap.read()
if not ret:
break

results = model.track(frame, persist=True, verbose=False, classes=VEHICLE_CLASSES)
boxes = results[0].boxes

if boxes.id is not None:
for xyxy, tid in zip(boxes.xyxy.cpu().numpy(), boxes.id.cpu().numpy()):
x1, y1, x2, y2 = map(int, xyxy)
cx, cy = (x1 + x2) / 2, float(y2)
if not is_in_roi((cx, cy), ROI_POINTS):
continue
s = calc_s(cy, y_min, y_max)
track_id = int(tid)
counter.update(track_id, s)
state = counter.tracks[track_id].state
draw_bbox_with_info(frame, (x1, y1, x2, y2), track_id, s, state)

draw_roi(frame, ROI_POINTS)
draw_band_lines(frame, ROI_POINTS, y_min, y_max, S_LOW, S_HIGH)
draw_counts(frame, counter.count_in, counter.count_out)
cv2.imshow("roi-counter", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break

cap.release()
cv2.destroyAllWindows()
print(f"入庫: {counter.count_in} 出庫: {counter.count_out}")


if __name__ == "__main__":
main()
55 changes: 55 additions & 0 deletions raspi/roi-counter/scripts/00_convert_fps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))

import cv2

# ── パラメータ ──────────────────────────────────────────────────────────────
VIDEO_SOURCE = "data/inputs/IMG_2787.MOV"
TARGET_FPS = 20.0
# ────────────────────────────────────────────────────────────────────────────


def main() -> None:
src = Path(VIDEO_SOURCE)
out_path = src.with_name(src.stem + "_fixed" + src.suffix)

cap = cv2.VideoCapture(str(src))
if not cap.isOpened():
print(f"[ERROR] 動画を開けません: {src}")
sys.exit(1)

src_fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))

print(f"入力: {src} ({src_fps:.2f} fps, {w}x{h}, {total_frames}フレーム)")
print(f"出力: {out_path} ({TARGET_FPS} fps)")

fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(str(out_path), fourcc, TARGET_FPS, (w, h))

# 元FPSとターゲットFPSの比率でフレームを間引く
step = src_fps / TARGET_FPS
next_idx = 0.0
frame_idx = 0
written = 0

while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
if frame_idx >= next_idx:
writer.write(frame)
next_idx += step
written += 1
frame_idx += 1

cap.release()
writer.release()
print(f"完了: {written}フレーム書き込み")


if __name__ == "__main__":
main()
Loading