diff --git "a/docs/04_ENV/ENV_03_RaspPi\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.txt" "b/docs/04_ENV/ENV_03_RaspPi\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.txt" index d40ac62..54b8989 100644 --- "a/docs/04_ENV/ENV_03_RaspPi\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.txt" +++ "b/docs/04_ENV/ENV_03_RaspPi\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.txt" @@ -79,10 +79,37 @@ 3. venv の作成・ライブラリインストール (Virtual Environment) ------------------------------------------------------------------------ - ※ Pi 側の環境構築は実機作業時に手順を確定し,追記する. + 3-1. venv の作成 + + $ cd /home/user/RobotCar + $ python3 -m venv .venv + + 3-2. venv の有効化 + + $ source .venv/bin/activate + + 3-3. pip のアップグレード + + (.venv) $ pip install --upgrade pip + + 3-4. ライブラリのインストール + + (.venv) $ pip install -r requirements_pi.txt + + ※ requirements_pi.txt は PC から転送済みであること. + + 3-5. .env ファイルの配置 + + PC から .env ファイルを転送するか,.env.example をコピーして + 値を設定する. + + (.venv) $ cp .env.example .env + (.venv) $ nano .env + + PC_IP には PC の IP アドレスを設定する. 4. 動作確認 (Verification) ------------------------------------------------------------------------ - ※ Pi 側の動作確認は実機作業時に手順を確定し,追記する. + ※ 動作確認の手順は実装の進捗に応じて追記する. diff --git "a/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" "b/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" index 4d4d0d2..9ca5efc 100644 --- "a/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" +++ "b/docs/04_ENV/ENV_04_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.txt" @@ -13,9 +13,8 @@ 1-1. 状態の表記 - ・(実装済み): ファイルが存在し,動作する状態. ・(未実装): 今後作成予定のファイル. - ・表記なし: ディレクトリのみ作成済み. + ・表記なし: 実装済み,またはディレクトリ. 2. ディレクトリ構成 (Directory Structure) @@ -24,9 +23,9 @@ 2-1. 全体構成 RobotCar/ - ├── CLAUDE.md (実装済み) - ├── requirements_pc.txt (実装済み) - ├── requirements_pi.txt (実装済み) + ├── CLAUDE.md + ├── requirements_pc.txt + ├── requirements_pi.txt ├── docs/ ドキュメント ├── src/ 自律走行用ソースコード │ ├── common/ 共通設定(PC・Pi 両方で使用) @@ -37,7 +36,7 @@ 2-2. src/common/ common/ - └── config.py (未実装) + └── config.py ・PC・Pi 間で共有する設定値を定義する. ・内容: ネットワーク設定,画像フォーマット,通信設定等. @@ -49,7 +48,7 @@ ├── gui/ GUI 関連 │ └── main_window.py (未実装) メインウィンドウ ├── comm/ 通信関連 - │ └── zmq_client.py (未実装) ZMQ 送受信 + │ └── zmq_client.py ZMQ 送受信 ├── steering/ 操舵量計算(独立モジュール) │ ├── base.py (未実装) 共通インターフェース │ └── pd_control.py (未実装) PD 制御の実装 @@ -61,7 +60,7 @@ pi/ ├── main.py (未実装) エントリーポイント ├── comm/ 通信関連 - │ └── zmq_client.py (未実装) ZMQ 送受信 + │ └── zmq_client.py ZMQ 送受信 ├── camera/ カメラ関連 │ └── capture.py (未実装) フレーム取得 └── motor/ モーター関連 diff --git a/requirements_pc.txt b/requirements_pc.txt index 4877e6c..506720a 100644 --- a/requirements_pc.txt +++ b/requirements_pc.txt @@ -2,3 +2,4 @@ opencv-python==4.13.0.92 pyzmq==27.1.0 numpy==2.4.3 +python-dotenv==1.2.2 diff --git a/requirements_pi.txt b/requirements_pi.txt index d073deb..8b753d4 100644 --- a/requirements_pi.txt +++ b/requirements_pi.txt @@ -1,3 +1,4 @@ pyzmq==27.1.0 picamera2 RPi.GPIO +python-dotenv==1.2.2 diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/common/__init__.py diff --git a/src/common/config.py b/src/common/config.py new file mode 100644 index 0000000..6928826 --- /dev/null +++ b/src/common/config.py @@ -0,0 +1,89 @@ +""" +config +プロジェクト共通の設定値を管理するモジュール +環境固有の値は .env ファイルから読み込む +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +# .env ファイルの読み込み(src/ の親ディレクトリを探索) +_env_path = Path(__file__).resolve().parent.parent.parent / ".env" +load_dotenv(_env_path) + +# ── ネットワーク設定(.env から読み込み) ────────────────────── + +# PC の IP アドレス +PC_IP: str = os.getenv("PC_IP", "127.0.0.1") + +# 画像送信ポート(Pi → PC) +IMAGE_PORT: int = int(os.getenv("IMAGE_PORT", "5555")) + +# 操舵量送信ポート(PC → Pi) +CONTROL_PORT: int = int(os.getenv("CONTROL_PORT", "5556")) + +# ── 画像設定 ────────────────────────────────────────────── + +# カメラ画像の幅 (px) +FRAME_WIDTH: int = 320 + +# カメラ画像の高さ (px) +FRAME_HEIGHT: int = 240 + +# JPEG 圧縮品質 (0-100) +JPEG_QUALITY: int = 55 + +# ── 通信設定 ────────────────────────────────────────────── + +# 操舵量の送信頻度 (Hz) +CONTROL_PUBLISH_HZ: float = 20.0 + +# 操舵量の受信タイムアウト (秒),超過でモーター停止 +CONTROL_TIMEOUT_SEC: float = 0.5 + +# ── モーター設定(GPIO ピン番号,BOARD モード) ─────────── + +# モーター A(左) +MA_IN1: int = 19 +MA_IN2: int = 21 +MA_PWM: int = 23 + +# モーター B(右) +MB_IN1: int = 15 +MB_IN2: int = 13 +MB_PWM: int = 11 + +# モーター PWM 周波数 (Hz) +MOTOR_PWM_FREQ: int = 100 + +# モーター極性反転フラグ +MOTOR_LEFT_REVERSED: bool = True +MOTOR_RIGHT_REVERSED: bool = True + +# ステアリング方向反転フラグ +STEER_REVERSED: bool = True + + +# ── アドレス生成ヘルパー ────────────────────────────────── + + +def image_bind_address() -> str: + """画像受信側(PC)のバインドアドレスを返す""" + return f"tcp://*:{IMAGE_PORT}" + + +def image_connect_address() -> str: + """画像送信側(Pi)の接続先アドレスを返す""" + return f"tcp://{PC_IP}:{IMAGE_PORT}" + + +def control_bind_address() -> str: + """操舵量送信側(PC)のバインドアドレスを返す""" + return f"tcp://*:{CONTROL_PORT}" + + +def control_connect_address() -> str: + """操舵量受信側(Pi)の接続先アドレスを返す""" + return f"tcp://{PC_IP}:{CONTROL_PORT}" diff --git a/src/pc/__init__.py b/src/pc/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/pc/__init__.py diff --git a/src/pc/comm/__init__.py b/src/pc/comm/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/pc/comm/__init__.py diff --git a/src/pc/comm/zmq_client.py b/src/pc/comm/zmq_client.py new file mode 100644 index 0000000..8cbd8d1 --- /dev/null +++ b/src/pc/comm/zmq_client.py @@ -0,0 +1,83 @@ +""" +zmq_client +PC 側の ZMQ 通信を担当するモジュール +画像の受信と操舵量の送信を行う +""" + +import json + +import cv2 +import numpy as np +import zmq + +from common import config + + +class PcZmqClient: + """PC 側の ZMQ 通信クライアント + + 画像受信(SUB)と操舵量送信(PUB)の2チャネルを管理する + """ + + def __init__(self) -> None: + self._context = zmq.Context() + self._image_socket: zmq.Socket | None = None + self._control_socket: zmq.Socket | None = None + + def start(self) -> None: + """通信ソケットを初期化してバインドする""" + + # 画像受信ソケット(SUB,Pi からの画像を受信) + self._image_socket = self._context.socket(zmq.SUB) + self._image_socket.setsockopt(zmq.CONFLATE, 1) + self._image_socket.setsockopt_string(zmq.SUBSCRIBE, "") + self._image_socket.bind(config.image_bind_address()) + + # 操舵量送信ソケット(PUB,Pi へ操舵量を送信) + self._control_socket = self._context.socket(zmq.PUB) + self._control_socket.bind(config.control_bind_address()) + + def receive_image(self) -> np.ndarray | None: + """画像を非ブロッキングで受信する + + Returns: + 受信した画像の NumPy 配列,受信データがない場合は None + """ + if self._image_socket is None: + return None + try: + data = self._image_socket.recv(zmq.NOBLOCK) + frame = cv2.imdecode( + np.frombuffer(data, dtype=np.uint8), + cv2.IMREAD_COLOR, + ) + return frame + except zmq.Again: + return None + + def send_control( + self, throttle: float, steer: float, + ) -> None: + """操舵量を送信する + + Args: + throttle: 前後方向の出力 (-1.0 ~ +1.0) + steer: 左右方向の出力 (-1.0 ~ +1.0) + """ + if self._control_socket is None: + return + payload = json.dumps({ + "throttle": throttle, + "steer": steer, + }).encode("utf-8") + self._control_socket.send(payload, zmq.NOBLOCK) + + def stop(self) -> None: + """通信ソケットを閉じる""" + if self._image_socket is not None: + self._image_socket.close() + self._image_socket = None + if self._control_socket is not None: + self._control_socket.close() + self._control_socket = None + self._context.term() diff --git a/src/pi/__init__.py b/src/pi/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/pi/__init__.py diff --git a/src/pi/camera/__init__.py b/src/pi/camera/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/pi/camera/__init__.py diff --git a/src/pi/comm/__init__.py b/src/pi/comm/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/pi/comm/__init__.py diff --git a/src/pi/comm/zmq_client.py b/src/pi/comm/zmq_client.py new file mode 100644 index 0000000..f553511 --- /dev/null +++ b/src/pi/comm/zmq_client.py @@ -0,0 +1,93 @@ +""" +zmq_client +Pi 側の ZMQ 通信を担当するモジュール +画像の送信と操舵量の受信を行う +""" + +import json +import time + +import cv2 +import numpy as np +import zmq + +from common import config + + +class PiZmqClient: + """Pi 側の ZMQ 通信クライアント + + 画像送信(PUB)と操舵量受信(SUB)の2チャネルを管理する + """ + + def __init__(self) -> None: + self._context = zmq.Context() + self._image_socket: zmq.Socket | None = None + self._control_socket: zmq.Socket | None = None + self._last_receive_time: float = 0.0 + + def start(self) -> None: + """通信ソケットを初期化して接続する""" + + # 画像送信ソケット(PUB,PC へ画像を送信) + self._image_socket = self._context.socket(zmq.PUB) + self._image_socket.setsockopt(zmq.CONFLATE, 1) + self._image_socket.connect(config.image_connect_address()) + + # 操舵量受信ソケット(SUB,PC からの操舵量を受信) + self._control_socket = self._context.socket(zmq.SUB) + self._control_socket.setsockopt(zmq.CONFLATE, 1) + self._control_socket.setsockopt_string(zmq.SUBSCRIBE, "") + self._control_socket.connect(config.control_connect_address()) + + self._last_receive_time = time.time() + + def send_image(self, frame: np.ndarray) -> None: + """画像を JPEG 圧縮して送信する + + Args: + frame: カメラから取得した画像の NumPy 配列 + """ + if self._image_socket is None: + return + _, encoded = cv2.imencode( + ".jpg", + frame, + [cv2.IMWRITE_JPEG_QUALITY, config.JPEG_QUALITY], + ) + self._image_socket.send(encoded.tobytes(), zmq.NOBLOCK) + + def receive_control(self) -> tuple[float, float] | None: + """操舵量を非ブロッキングで受信する + + Returns: + (throttle, steer) のタプル,受信データがない場合は None + """ + if self._control_socket is None: + return None + try: + data = self._control_socket.recv(zmq.NOBLOCK) + payload = json.loads(data.decode("utf-8")) + self._last_receive_time = time.time() + return (payload["throttle"], payload["steer"]) + except zmq.Again: + return None + + def is_timeout(self) -> bool: + """操舵量の受信がタイムアウトしたか判定する + + Returns: + タイムアウトしていれば True + """ + elapsed = time.time() - self._last_receive_time + return elapsed > config.CONTROL_TIMEOUT_SEC + + def stop(self) -> None: + """通信ソケットを閉じる""" + if self._image_socket is not None: + self._image_socket.close() + self._image_socket = None + if self._control_socket is not None: + self._control_socket.close() + self._control_socket = None + self._context.term() diff --git a/src/pi/motor/__init__.py b/src/pi/motor/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/pi/motor/__init__.py