From 7913c230beb550e0d7e8261fc0b0173983e2a298 Mon Sep 17 00:00:00 2001 From: voluntas Date: Wed, 20 Mar 2024 14:19:03 +0900 Subject: [PATCH] =?UTF-8?q?sora-python-sdk-samples=20=E3=81=8B=E3=82=89?= =?UTF-8?q?=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/extensions.json | 3 + .vscode/settings.json | 14 +- examples/.env.template | 13 ++ examples/.gitignore | 7 + examples/.python-version | 1 + examples/README.md | 59 ++++++++ examples/pyproject.toml | 41 ++++++ examples/requirements-dev.lock | 76 +++++++++++ examples/requirements.lock | 68 ++++++++++ examples/src/media/__init__.py | 0 examples/src/media/recvonly.py | 188 ++++++++++++++++++++++++++ examples/src/media/sendonly.py | 208 +++++++++++++++++++++++++++++ examples/src/messaging/__init__.py | 102 ++++++++++++++ examples/src/messaging/recvonly.py | 77 +++++++++++ examples/src/messaging/sendonly.py | 70 ++++++++++ examples/src/messaging/sendrecv.py | 70 ++++++++++ examples/src/ml/hideface_sender.py | 197 +++++++++++++++++++++++++++ examples/src/ml/shiguremaru.png | Bin 0 -> 24990 bytes examples/sync.sh | 19 +++ 19 files changed, 1207 insertions(+), 6 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 examples/.env.template create mode 100644 examples/.gitignore create mode 100644 examples/.python-version create mode 100644 examples/README.md create mode 100644 examples/pyproject.toml create mode 100644 examples/requirements-dev.lock create mode 100644 examples/requirements.lock create mode 100644 examples/src/media/__init__.py create mode 100644 examples/src/media/recvonly.py create mode 100644 examples/src/media/sendonly.py create mode 100644 examples/src/messaging/__init__.py create mode 100644 examples/src/messaging/recvonly.py create mode 100644 examples/src/messaging/sendonly.py create mode 100644 examples/src/messaging/sendrecv.py create mode 100644 examples/src/ml/hideface_sender.py create mode 100644 examples/src/ml/shiguremaru.png create mode 100755 examples/sync.sh diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..8dca3717 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["charliermarsh.ruff", "ms-python.mypy-type-checker"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 583caae4..8b89edda 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,6 @@ { + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, "python.analysis.diagnosticSeverityOverrides": { "reportMissingImports": "none", "reportUnusedImport": "information" @@ -6,10 +8,12 @@ "[python]": { "editor.formatOnSave": true, "editor.formatOnSaveMode": "file", + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.autoIndent": "full", "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - }, - "editor.defaultFormatter": "charliermarsh.ruff" + "source.fixAll.ruff": "always", + "source.organizeImports.ruff": "explicit" + } }, "[cpp]": { "editor.formatOnSave": true @@ -119,7 +123,5 @@ }, "C_Cpp.errorSquiggles": "disabled", // nanobind 周りでエラーが消えないので全部消す - "editor.tabSize": 2, - "python.analysis.typeCheckingMode": "off", - "cSpell.words": ["dtype", "imshow", "samplerate", "sounddevice"] + "editor.tabSize": 2 } diff --git a/examples/.env.template b/examples/.env.template new file mode 100644 index 00000000..2f5a43a3 --- /dev/null +++ b/examples/.env.template @@ -0,0 +1,13 @@ +# コマンドライン引数 もしくは 環境変数での指定が必須なパラメーター +# SORA_SIGNALING_URLS カンマ区切りで複数指定可能 +SORA_SIGNALING_URLS=wss://1.example.com./signaling,wss://2.example.com/signaling +SORA_CHANNEL_ID=sora +SORA_METADATA='{"access_token": "secret"}' + +# オプション設定 +# SORA_VIDEO_CODEC_TYPE=vp9 +# SORA_VIDEO_BIT_RATE=500 +# SORA_CAMERA_ID=0 +# SORA_VIDEO_WIDTH=640 +# SORA_VIDEO_HEIGHT=480 +# SORA_MESSAGING_LABEL=#sora-devtools \ No newline at end of file diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 00000000..929da49b --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,7 @@ +.venv +.ruff_cache +.mypy_cache +*.pyc +__pycache__ +.env* +!.env.template \ No newline at end of file diff --git a/examples/.python-version b/examples/.python-version new file mode 100644 index 00000000..9ad6380c --- /dev/null +++ b/examples/.python-version @@ -0,0 +1 @@ +3.8.18 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..9b7d6160 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,59 @@ +# Python Sora SDK サンプル集 + +## About Shiguredo's open source software + +We will not respond to PRs or issues that have not been discussed on Discord. Also, Discord is only available in Japanese. + +Please read https://github.com/shiguredo/oss/blob/master/README.en.md before use. + +## 時雨堂のオープンソースソフトウェアについて + +利用前に https://github.com/shiguredo/oss をお読みください。 + +## サンプルコードの実行方法 + +[Rye](https://github.com/mitsuhiko/rye) というパッケージマネージャーを利用しています。 + +Linux と macOS の場合は `curl -sSf https://rye-up.com/get | bash` でインストール可能です。 +Windows は https://rye-up.com/ の Installation Instructions を確認してください。 + +### 依存パッケージのビルド + +```console +$ rye sync +``` + +### サンプルコードの実行 + +```console +$ rye run media_recvonly --signaling-urls wss://1.example.com/signaling wss://2.example.com/signaling --channel-id sora +``` + +### コマンドラインの代わりに環境変数を利用する + +```console +$ cp .env.template .env +# .env に必要な変数を設定してください。 +$ rye run media_recvonly +``` + +## ライセンス + +Apache License 2.0 + +``` +Copyright 2023-2024, tnoho (Original Author) +Copyright 2023-2024, Shiguredo Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 00000000..2f6e120e --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "sora-sdk-samples" +version = "2024.1.0" +description = "Sora Python SDK Samples" +authors = [{ name = "Shiguredo Inc." }] +dependencies = [ + "opencv-python~=4.9.0.80", + "opencv-python-headless~=4.9.0.80", + "sounddevice~=0.4.6", + "sora-sdk>=2024.1.0", + "mediapipe~=0.10.1", + "python-dotenv>=1.0.1", +] +readme = "README.md" +requires-python = ">= 3.8" + +[project.scripts] +media_sendonly = "media.sendonly:sendonly" +media_recvonly = "media.recvonly:recvonly" +messaging_sendrecv = "messaging.sendrecv:sendrecv" +messaging_sendonly = "messaging.sendonly:sendonly" +messaging_recvonly = "messaging.recvonly:recvonly" +hideface_sender = "ml.hideface_sender:hideface_sender" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.rye] +managed = true +dev-dependencies = ["ruff>=0.3.0", "mypy>=1.8.0"] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/media", "src/messaging", "src/ml"] + +[tool.ruff] +line-length = 100 +indent-width = 4 diff --git a/examples/requirements-dev.lock b/examples/requirements-dev.lock new file mode 100644 index 00000000..0e18ec18 --- /dev/null +++ b/examples/requirements-dev.lock @@ -0,0 +1,76 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +absl-py==1.4.0 + # via mediapipe +attrs==23.1.0 + # via mediapipe +cffi==1.15.1 + # via sounddevice +contourpy==1.1.0 + # via matplotlib +cycler==0.11.0 + # via matplotlib +flatbuffers==23.5.26 + # via mediapipe +fonttools==4.40.0 + # via matplotlib +importlib-resources==5.12.0 + # via matplotlib +kiwisolver==1.4.4 + # via matplotlib +matplotlib==3.7.2 + # via mediapipe +mediapipe==0.10.1 + # via sora-sdk-samples +mypy==1.8.0 +mypy-extensions==1.0.0 + # via mypy +numpy==1.24.4 + # via contourpy + # via matplotlib + # via mediapipe + # via opencv-contrib-python + # via opencv-python + # via opencv-python-headless +opencv-contrib-python==4.8.0.74 + # via mediapipe +opencv-python==4.9.0.80 + # via sora-sdk-samples +opencv-python-headless==4.9.0.80 + # via sora-sdk-samples +packaging==23.1 + # via matplotlib +pillow==10.0.0 + # via matplotlib +protobuf==3.20.3 + # via mediapipe +pycparser==2.21 + # via cffi +pyparsing==3.0.9 + # via matplotlib +python-dateutil==2.8.2 + # via matplotlib +python-dotenv==1.0.1 + # via sora-sdk-samples +ruff==0.3.0 +six==1.16.0 + # via python-dateutil +sora-sdk==2024.1.0 + # via sora-sdk-samples +sounddevice==0.4.6 + # via mediapipe + # via sora-sdk-samples +tomli==2.0.1 + # via mypy +typing-extensions==4.10.0 + # via mypy +zipp==3.17.0 + # via importlib-resources diff --git a/examples/requirements.lock b/examples/requirements.lock new file mode 100644 index 00000000..806fcc4b --- /dev/null +++ b/examples/requirements.lock @@ -0,0 +1,68 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +absl-py==1.4.0 + # via mediapipe +attrs==23.1.0 + # via mediapipe +cffi==1.15.1 + # via sounddevice +contourpy==1.1.0 + # via matplotlib +cycler==0.11.0 + # via matplotlib +flatbuffers==23.5.26 + # via mediapipe +fonttools==4.40.0 + # via matplotlib +importlib-resources==5.12.0 + # via matplotlib +kiwisolver==1.4.4 + # via matplotlib +matplotlib==3.7.2 + # via mediapipe +mediapipe==0.10.1 + # via sora-sdk-samples +numpy==1.24.4 + # via contourpy + # via matplotlib + # via mediapipe + # via opencv-contrib-python + # via opencv-python + # via opencv-python-headless +opencv-contrib-python==4.8.0.74 + # via mediapipe +opencv-python==4.9.0.80 + # via sora-sdk-samples +opencv-python-headless==4.9.0.80 + # via sora-sdk-samples +packaging==23.1 + # via matplotlib +pillow==10.0.0 + # via matplotlib +protobuf==3.20.3 + # via mediapipe +pycparser==2.21 + # via cffi +pyparsing==3.0.9 + # via matplotlib +python-dateutil==2.8.2 + # via matplotlib +python-dotenv==1.0.1 + # via sora-sdk-samples +six==1.16.0 + # via python-dateutil +sora-sdk==2024.1.0 + # via sora-sdk-samples +sounddevice==0.4.6 + # via mediapipe + # via sora-sdk-samples +zipp==3.17.0 + # via importlib-resources diff --git a/examples/src/media/__init__.py b/examples/src/media/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/src/media/recvonly.py b/examples/src/media/recvonly.py new file mode 100644 index 00000000..8f21cdf1 --- /dev/null +++ b/examples/src/media/recvonly.py @@ -0,0 +1,188 @@ +import argparse +import json +import os +import queue +from threading import Event +from typing import Any, Dict, List, Optional + +import cv2 +import sounddevice +from dotenv import load_dotenv +from numpy import ndarray +from sora_sdk import ( + Sora, + SoraAudioSink, + SoraConnection, + SoraMediaTrack, + SoraSignalingErrorCode, + SoraVideoFrame, + SoraVideoSink, +) + + +class Recvonly: + def __init__( + self, + # python 3.8 まで対応なので list[str] ではなく List[str] にする + signaling_urls: List[str], + channel_id: str, + metadata: Optional[Dict[str, Any]], + openh264: Optional[str], + output_frequency: int = 16000, + output_channels: int = 1, + ): + self._output_frequency = output_frequency + self._output_channels = output_channels + + self._sora: Sora = Sora(openh264=openh264) + self._connection: SoraConnection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="recvonly", + channel_id=channel_id, + metadata=metadata, + ) + self._connection_id = "" + self._connected = Event() + self._closed = False + self._default_connection_timeout_s = 10.0 + + self._audio_sink: Optional[SoraAudioSink] = None + self._video_sink: Optional[SoraVideoSink] = None + + # SoraVideoFrame を格納するキュー + self._q_out: queue.Queue = queue.Queue() + + self._connection.on_set_offer = self._on_set_offer + self._connection.on_notify = self._on_notify + self._connection.on_disconnect = self._on_disconnect + self._connection.on_track = self._on_track + + def connect(self): + self._connection.connect() + + assert self._connected.wait( + timeout=self._default_connection_timeout_s + ), "接続に失敗しました" + + def disconnect(self): + self._connection.disconnect() + + def _on_set_offer(self, raw_message: str): + message: Dict[str, Any] = json.loads(raw_message) + if message["type"] == "offer": + # "type": "offer" に入ってくる自分の connection_id を保存する + self._connection_id = message["connection_id"] + + def _on_notify(self, raw_message: str): + message: Dict[str, Any] = json.loads(raw_message) + # "type": "notify" の "connection.created" で通知される connection_id が + # 自分の connection_id と一致する場合に接続完了とする + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print("Sora に接続しました") + self._connected.set() + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str): + print(f"Sora から切断されました: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed = True + + def _on_video_frame(self, frame: SoraVideoFrame): + # キューに SoraVideoFrame を入れる + self._q_out.put(frame) + + def _on_track(self, track: SoraMediaTrack): + if track.kind == "audio": + self._audio_sink = SoraAudioSink(track, self._output_frequency, self._output_channels) + if track.kind == "video": + self._video_sink = SoraVideoSink(track) + self._video_sink.on_frame = self._on_video_frame + + def _callback(self, outdata: ndarray, frames: int, time, status: sounddevice.CallbackFlags): + if self._audio_sink is not None: + success, data = self._audio_sink.read(frames) + if success: + if data.shape[0] != frames: + print("音声データが十分ではありません", data.shape, frames) + outdata[:] = data + else: + print("音声データを取得できません") + + def run(self): + # サウンドデバイスのOutputStreamを使って音声出力を設定 + with sounddevice.OutputStream( + channels=self._output_channels, + callback=self._callback, + samplerate=self._output_frequency, + dtype="int16", + ): + self.connect() + try: + while self._connected.is_set(): + # Windows 環境の場合 timeout を入れておかないと Queue.get() で + # ブロックしたときに脱出方法がなくなる。 + try: + # キューから SoraVideoFrame を取り出す + frame = self._q_out.get(timeout=1) + except queue.Empty: + continue + # 画像を表示する + cv2.imshow("frame", frame.data()) + # これは削除してよさそう + if cv2.waitKey(1) & 0xFF == ord("q"): + break + except KeyboardInterrupt: + pass + finally: + self.disconnect() + + # すべてのウィンドウを破棄 + cv2.destroyAllWindows() + + +def recvonly(): + # .env ファイル読み込み + load_dotenv() + parser = argparse.ArgumentParser() + + # 必須引数 + default_signaling_urls = None + if urls := os.getenv("SORA_SIGNALING_URLS"): + # SORA_SIGNALING_URLS 環境変数はカンマ区切りで複数指定可能 + default_signaling_urls = urls.split(",") + parser.add_argument( + "--signaling-urls", + default=default_signaling_urls, + type=str, + nargs="+", + required=not default_signaling_urls, + help="シグナリング URL", + ) + default_channel_id = os.getenv("SORA_CHANNEL_ID") + parser.add_argument( + "--channel-id", + default=default_channel_id, + required=not default_channel_id, + help="チャネルID", + ) + + # オプション引数 + parser.add_argument("--metadata", default=os.getenv("SORA_METADATA"), help="メタデータ JSON") + parser.add_argument( + "--openh264", type=str, default=None, help="OpenH264 の共有ライブラリへのパス" + ) + args = parser.parse_args() + + metadata = None + if args.metadata: + metadata = json.loads(args.metadata) + + recvonly = Recvonly(args.signaling_urls, args.channel_id, metadata, args.openh264) + recvonly.run() + + +if __name__ == "__main__": + recvonly() diff --git a/examples/src/media/sendonly.py b/examples/src/media/sendonly.py new file mode 100644 index 00000000..6d34e5ec --- /dev/null +++ b/examples/src/media/sendonly.py @@ -0,0 +1,208 @@ +import argparse +import json +import os +from threading import Event +from typing import Any, Dict, List, Optional + +import cv2 +import sounddevice +from dotenv import load_dotenv +from numpy import ndarray +from sora_sdk import Sora, SoraConnection, SoraSignalingErrorCode + + +class SendOnly: + def __init__( + self, + # python 3.8 まで対応なので list[str] ではなく List[str] にする + signaling_urls: List[str], + channel_id: str, + metadata: Optional[Dict[str, Any]], + camera_id: int, + video_codec_type: str, + video_bit_rate: int, + video_width: Optional[int], + video_height: Optional[int], + openh264: Optional[str], + audio_channels: int = 1, + audio_sample_rate: int = 16000, + ): + self.audio_channels = audio_channels + self.audio_sample_rate = audio_sample_rate + + self._sora: Sora = Sora(openh264=openh264) + + self._audio_source = self._sora.create_audio_source( + self.audio_channels, self.audio_sample_rate + ) + self._video_source = self._sora.create_video_source() + + self._connection: SoraConnection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="sendonly", + channel_id=channel_id, + metadata=metadata, + video_codec_type=video_codec_type, + video_bit_rate=video_bit_rate, + audio_source=self._audio_source, + video_source=self._video_source, + ) + self._connection_id = "" + self._connected = Event() + self._closed = False + self._default_connection_timeout_s = 10.0 + + self._connection.on_set_offer = self._on_set_offer + self._connection.on_notify = self._on_notify + self._connection.on_disconnect = self._on_disconnect + + self._video_capture = cv2.VideoCapture(camera_id) + if video_width is not None: + self._video_capture.set(cv2.CAP_PROP_FRAME_WIDTH, video_width) + if video_height is not None: + self._video_capture.set(cv2.CAP_PROP_FRAME_HEIGHT, video_height) + + def connect(self): + self._connection.connect() + + assert self._connected.wait( + timeout=self._default_connection_timeout_s + ), "接続がタイムアウトしました" + + def disconnect(self): + self._connection.disconnect() + + def _on_notify(self, raw_message: str): + message: Dict[str, Any] = json.loads(raw_message) + # "type": "notify" の "connection.created" で通知される connection_id が + # 自分の connection_id と一致する場合に接続完了とする + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print("Sora に接続しました") + self._connected.set() + + def _on_set_offer(self, raw_message: str): + message: Dict[str, Any] = json.loads(raw_message) + if message["type"] == "offer": + # "type": "offer" に入ってくる自分の connection_id を保存する + self._connection_id = message["connection_id"] + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str): + print(f"Sora から切断されました: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed = True + + def _callback(self, indata: ndarray, frames: int, time, status: sounddevice.CallbackFlags): + self._audio_source.on_data(indata) + + def run(self): + # 音声デバイスの入力を Sora に送信する設定 + with sounddevice.InputStream( + samplerate=self.audio_sample_rate, + channels=self.audio_channels, + dtype="int16", + callback=self._callback, + ): + self.connect() + try: + while self._connected.is_set(): + # 取得したフレームを Sora に送信する + success, frame = self._video_capture.read() + if not success: + continue + self._video_source.on_captured(frame) + except KeyboardInterrupt: + pass + finally: + self.disconnect() + self._video_capture.release() + + +def sendonly(): + # .env ファイルを読み込む + load_dotenv() + parser = argparse.ArgumentParser() + + # 必須引数 + default_signaling_urls = None + if urls := os.getenv("SORA_SIGNALING_URLS"): + # SORA_SIGNALING_URLS 環境変数はカンマ区切りで複数指定可能 + default_signaling_urls = urls.split(",") + parser.add_argument( + "--signaling-urls", + default=default_signaling_urls, + type=str, + nargs="+", + required=not default_signaling_urls, + help="シグナリング URL", + ) + default_channel_id = os.getenv("SORA_CHANNEL_ID") + parser.add_argument( + "--channel-id", + default=default_channel_id, + required=not default_channel_id, + help="チャネルID", + ) + + # オプション引数 + parser.add_argument( + "--video-codec-type", + # Sora のデフォルト値と合わせる + default=os.getenv("SORA_VIDEO_CODEC_TYPE", "VP9"), + help="映像コーデックの種類", + ) + parser.add_argument( + "--video-bit-rate", + type=int, + # Sora のデフォルト値と合わせる + default=int(os.getenv("SORA_VIDEO_BIT_RATE", "500")), + help="映像ビットレート", + ) + parser.add_argument("--metadata", default=os.getenv("SORA_METADATA"), help="メタデータ JSON") + parser.add_argument( + "--camera-id", + type=int, + default=int(os.getenv("SORA_CAMERA_ID", "0")), + help="cv2.VideoCapture() に渡すカメラ ID", + ) + parser.add_argument( + "--video-width", + type=int, + default=int(os.getenv("SORA_VIDEO_WIDTH", "640")), + help="入力カメラ映像の横幅のヒント", + ) + parser.add_argument( + "--video-height", + type=int, + default=int(os.getenv("SORA_VIDEO_HEIGHT", "360")), + help="入力カメラ映像の高さのヒント", + ) + parser.add_argument( + "--openh264", type=str, default=None, help="OpenH264 の共有ライブラリへのパス" + ) + args = parser.parse_args() + + # metadata は JSON 形式で指定するので一同 JSON 形式で読み込む + metadata = None + if args.metadata: + metadata = json.loads(args.metadata) + + sendonly = SendOnly( + args.signaling_urls, + args.channel_id, + metadata, + args.camera_id, + args.video_codec_type, + args.video_bit_rate, + args.video_width, + args.video_height, + args.openh264, + ) + sendonly.run() + + +if __name__ == "__main__": + sendonly() diff --git a/examples/src/messaging/__init__.py b/examples/src/messaging/__init__.py new file mode 100644 index 00000000..83d22eba --- /dev/null +++ b/examples/src/messaging/__init__.py @@ -0,0 +1,102 @@ +import json +import random +import time +from threading import Event +from typing import Any, Dict, List, Optional + +from sora_sdk import Sora, SoraConnection, SoraSignalingErrorCode + + +class Messaging: + def __init__( + self, + # python 3.8 まで対応なので list[str] ではなく List[str] にする + signaling_urls: List[str], + channel_id: str, + data_channels: List[Dict[str, Any]], + metadata: Optional[Dict[str, Any]], + ): + self._data_channels = data_channels + + self._sora = Sora() + self._connection: SoraConnection = self._sora.create_connection( + signaling_urls=signaling_urls, + role="sendrecv", + channel_id=channel_id, + metadata=metadata, + audio=False, + video=False, + data_channels=self._data_channels, + data_channel_signaling=True, + ) + self._connection_id: str = "" + + self._connected = Event() + self._closed = False + self._label = data_channels[0]["label"] + self._sendable_data_channels: set = set() + self._is_data_channel_ready = False + + self.sender_id = random.randint(1, 10000) + + self._connection.on_set_offer = self._on_set_offer + self._connection.on_notify = self._on_notify + self._connection.on_data_channel = self._on_data_channel + self._connection.on_message = self._on_message + self._connection.on_disconnect = self._on_disconnect + + @property + def closed(self): + return self._closed + + def connect(self): + self._connection.connect() + + assert self._connected.wait(10), "接続に失敗しました" + + def disconnect(self): + self._connection.disconnect() + + def send(self, data: bytes): + # on_data_channel() が呼ばれるまではデータチャネルの準備ができていないので待機 + while not self._is_data_channel_ready and not self._closed: + time.sleep(0.01) + + self._connection.send_data_channel(self._label, data) + + def _on_set_offer(self, raw_message: str): + message: Dict[str, Any] = json.loads(raw_message) + if message["type"] == "offer": + # "type": "offer" に入ってくる自分の connection_id を保存する + self._connection_id = message["connection_id"] + + def _on_notify(self, raw_message: str): + message: Dict[str, Any] = json.loads(raw_message) + # "type": "notify" の "connection.created" で通知される connection_id が + # 自分の connection_id と一致する場合に接続完了とする + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print("Sora に接続しました") + self._connected.set() + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str): + print(f"Sora から切断されました: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed = True + + def _on_message(self, label: str, data: bytes): + print(f"メッセージを受信しました: label={label}, data={data.decode('utf-8')}") + + def _on_data_channel(self, label: str): + for data_channel in self._data_channels: + if data_channel["label"] != label: + continue + + if data_channel["direction"] in ["sendrecv", "sendonly"]: + self._sendable_data_channels.add(label) + # データチャネルの準備ができたのでフラグを立てる + self._is_data_channel_ready = True + break diff --git a/examples/src/messaging/recvonly.py b/examples/src/messaging/recvonly.py new file mode 100644 index 00000000..37041ace --- /dev/null +++ b/examples/src/messaging/recvonly.py @@ -0,0 +1,77 @@ +import argparse +import json +import os +import time + +from dotenv import load_dotenv + +from messaging import Messaging + + +def recvonly(): + # .env ファイルを読み込む + load_dotenv() + + parser = argparse.ArgumentParser() + + # 必須引数 + default_signaling_urls = None + if urls := os.getenv("SORA_SIGNALING_URLS"): + # カンマ区切りで複数指定可能 + default_signaling_urls = urls.split(",") + parser.add_argument( + "--signaling-urls", + default=default_signaling_urls, + type=str, + nargs="+", + required=not default_signaling_urls, + help="シグナリング URL", + ) + default_channel_id = os.getenv("SORA_CHANNEL_ID") + parser.add_argument( + "--channel-id", + default=default_channel_id, + required=not default_channel_id, + help="チャネルID", + ) + default_messaging_label = os.getenv("SORA_MESSAGING_LABEL") + parser.add_argument( + "--messaging-label", + default=default_messaging_label, + type=str, + nargs="+", + required=not default_messaging_label, + help="データチャネルのラベル名", + ) + + # オプション引数 + parser.add_argument("--metadata", default=os.getenv("SORA_METADATA"), help="メタデータ JSON") + args = parser.parse_args() + + metadata = {} + if args.metadata: + metadata = json.loads(args.metadata) + + data_channels = [{"label": args.messaging_label, "direction": "recvonly"}] + messaging_recvonly = Messaging( + args.signaling_urls, + args.channel_id, + data_channels, + metadata, + ) + + # Sora に接続する + messaging_recvonly.connect() + try: + # Ctrl+C が押される or 切断されるまでメッセージ受信を待機 + while not messaging_recvonly.closed: + time.sleep(0.01) + except KeyboardInterrupt: + pass + finally: + # Sora から切断する(すでに切断済みの場合には無視される) + messaging_recvonly.disconnect() + + +if __name__ == "__main__": + recvonly() diff --git a/examples/src/messaging/sendonly.py b/examples/src/messaging/sendonly.py new file mode 100644 index 00000000..08b27cb5 --- /dev/null +++ b/examples/src/messaging/sendonly.py @@ -0,0 +1,70 @@ +import argparse +import json +import os + +from dotenv import load_dotenv + +from messaging import Messaging + + +def sendonly(): + # .env ファイルを読み込む + load_dotenv() + + parser = argparse.ArgumentParser() + + # 必須引数 + default_signaling_urls = None + if urls := os.getenv("SORA_SIGNALING_URLS"): + # カンマ区切りで複数指定可能 + default_signaling_urls = urls.split(",") + parser.add_argument( + "--signaling-urls", + default=default_signaling_urls, + type=str, + nargs="+", + required=not default_signaling_urls, + help="シグナリング URL", + ) + default_channel_id = os.getenv("SORA_CHANNEL_ID") + parser.add_argument( + "--channel-id", + default=default_channel_id, + required=not default_channel_id, + help="チャネルID", + ) + default_messaging_label = os.getenv("SORA_MESSAGING_LABEL", "#example") + parser.add_argument( + "--messaging-label", + default=default_messaging_label, + required=not default_messaging_label, + help="データチャネルのラベル名", + ) + + # オプション引数 + parser.add_argument("--metadata", default=os.getenv("SORA_METADATA"), help="メタデータ JSON") + args = parser.parse_args() + + metadata = None + if args.metadata: + metadata = json.loads(args.metadata) + + # data_channels 組み立て + data_channels = [{"label": args.messaging_label, "direction": "sendonly"}] + messaging_sendonly = Messaging(args.signaling_urls, args.channel_id, data_channels, metadata) + + # Sora に接続する + messaging_sendonly.connect() + try: + while not messaging_sendonly.closed: + # input で入力された文字列を utf-8 でエンコードして送信 + message = input("Enter キーを押すと送信します: ") + messaging_sendonly.send(message.encode("utf-8")) + except KeyboardInterrupt: + pass + finally: + messaging_sendonly.disconnect() + + +if __name__ == "__main__": + sendonly() diff --git a/examples/src/messaging/sendrecv.py b/examples/src/messaging/sendrecv.py new file mode 100644 index 00000000..a24ae110 --- /dev/null +++ b/examples/src/messaging/sendrecv.py @@ -0,0 +1,70 @@ +import argparse +import json +import os + +from dotenv import load_dotenv + +from messaging import Messaging + + +def sendrecv(): + # .env ファイルを読み込む + load_dotenv() + + parser = argparse.ArgumentParser() + + # 必須引数 + default_signaling_urls = None + if urls := os.getenv("SORA_SIGNALING_URLS"): + # カンマ区切りで複数指定可能 + default_signaling_urls = urls.split(",") + parser.add_argument( + "--signaling-urls", + default=default_signaling_urls, + type=str, + nargs="+", + required=not default_signaling_urls, + help="シグナリング URL", + ) + default_channel_id = os.getenv("SORA_CHANNEL_ID") + parser.add_argument( + "--channel-id", + default=default_channel_id, + required=not default_channel_id, + help="チャネルID", + ) + default_messaging_label = os.getenv("SORA_MESSAGING_LABEL") + parser.add_argument( + "--messaging-label", + default=default_messaging_label, + type=str, + nargs="+", + required=not default_messaging_label, + help="データチャネルのラベル名", + ) + + # オプション引数 + parser.add_argument("--metadata", default=os.getenv("SORA_METADATA"), help="メタデータ JSON") + args = parser.parse_args() + + metadata = None + if args.metadata: + metadata = json.loads(args.metadata) + + data_channels = [{"label": args.messaging_label, "direction": "sendrecv"}] + messaging_sendrecv = Messaging(args.signaling_urls, args.channel_id, data_channels, metadata) + # Sora に接続する + messaging_sendrecv.connect() + try: + while not messaging_sendrecv.closed: + # input で入力された文字列を utf-8 でエンコードして送信 + message = input() + messaging_sendrecv.send(message.encode("utf-8")) + except KeyboardInterrupt: + pass + finally: + messaging_sendrecv.disconnect() + + +if __name__ == "__main__": + sendrecv() diff --git a/examples/src/ml/hideface_sender.py b/examples/src/ml/hideface_sender.py new file mode 100644 index 00000000..0dda65d4 --- /dev/null +++ b/examples/src/ml/hideface_sender.py @@ -0,0 +1,197 @@ +import argparse +import json +import math +import os +from pathlib import Path +from threading import Event +from typing import Any, Dict, List, Optional + +import cv2 +import mediapipe as mp +import numpy as np +from cv2.typing import MatLike +from dotenv import load_dotenv +from PIL import Image +from sora_sdk import Sora, SoraSignalingErrorCode, SoraVideoSource + + +class LogoStreamer: + def __init__( + self, + signaling_urls: List[str], + role: str, + channel_id: str, + metadata: Optional[Dict[str, Any]], + camera_id: int, + video_width: Optional[int], + video_height: Optional[int], + ): + self.mp_face_detection = mp.solutions.face_detection + + self._sora = Sora(openh264=None) + self._video_source: SoraVideoSource = self._sora.create_video_source() + self._connection = self._sora.create_connection( + signaling_urls=signaling_urls, + role=role, + channel_id=channel_id, + metadata=metadata, + video_codec_type=None, + video_bit_rate=500, + video_source=self._video_source, + ) + self._connection_id = "" + + self._connected = Event() + self._closed = False + self._default_connection_timeout_s = 10.0 + + self._connection.on_set_offer = self._on_set_offer + self._connection.on_notify = self._on_notify + self._connection.on_disconnect = self._on_disconnect + + self._video_capture = cv2.VideoCapture(camera_id) + if video_width is not None: + self._video_capture.set(cv2.CAP_PROP_FRAME_WIDTH, video_width) + if video_height is not None: + self._video_capture.set(cv2.CAP_PROP_FRAME_HEIGHT, video_height) + + # ロゴを読み込む + self._logo = Image.open(Path(__file__).parent.joinpath("shiguremaru.png")) + + def connect(self): + self._connection.connect() + + assert self._connected.wait( + timeout=self._default_connection_timeout_s + ), "接続に失敗しました" + + def disconnect(self): + self._connection.disconnect() + + def _on_disconnect(self, error_code: SoraSignalingErrorCode, message: str): + print(f"Sora から切断されました: error_code='{error_code}' message='{message}'") + self._connected.clear() + self._closed = True + + def _on_set_offer(self, raw_message: str): + message = json.loads(raw_message) + if message["type"] == "offer": + self._connection_id = message["connection_id"] + + def _on_notify(self, raw_message: str): + message = json.loads(raw_message) + if ( + message["type"] == "notify" + and message["event_type"] == "connection.created" + and message["connection_id"] == self._connection_id + ): + print("Sora に接続しました") + self._connected.set() + + def run(self): + self.connect() + try: + # 顔検出を用意する + # TODO: face_detection の型を調べる + with self.mp_face_detection.FaceDetection( + model_selection=0, min_detection_confidence=0.5 + ) as face_detection: + angle = 0 + while self._connected.is_set() and self._video_capture.isOpened(): + # フレームを取得する + success, frame = self._video_capture.read() + if not success: + continue + angle = self.run_one_frame(face_detection, angle, frame) + except KeyboardInterrupt: + pass + finally: + self.disconnect() + self._video_capture.release() + + def run_one_frame(self, face_detection, angle: int, frame: MatLike): + # 高速化の呪文 + frame.flags.writeable = False + # mediapipe や PIL で処理できるように色の順序を変える + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # mediapipe で顔を検出する + results = face_detection.process(frame) + + frame_height, frame_width, _ = frame.shape + # PIL で処理できるように画像を変換する + pil_image = Image.fromarray(frame) + + # ロゴを回しておく + rotated_logo = self._logo.rotate(angle) + angle += 1 + if angle >= 360: + angle = 0 + if results.detections: + for detection in results.detections: + location = detection.location_data + if not location.HasField("relative_bounding_box"): + continue + bb = location.relative_bounding_box + + # 正規化されているので逆正規化を行う + w_px = math.floor(bb.width * frame_width) + h_px = math.floor(bb.height * frame_height) + x_px = min(math.floor(bb.xmin * frame_width), frame_width - 1) + y_px = min(math.floor(bb.ymin * frame_height), frame_height - 1) + + # 検出領域は顔に対して小さいため、顔全体が覆われるように検出領域を大きくする + fixed_w_px = math.floor(w_px * 1.6) + fixed_h_px = math.floor(h_px * 1.6) + # 大きくした分、座標がずれてしまうため顔の中心になるように座標を補正する + fixed_x_px = max(0, math.floor(x_px - (fixed_w_px - w_px) / 2)) + # 検出領域は顔であり頭が入っていないため、上寄りになるように座標を補正する + fixed_y_px = max(0, math.floor(y_px - (fixed_h_px - h_px))) + + # ロゴをリサイズする + resized_logo = rotated_logo.resize((fixed_w_px, fixed_h_px)) + pil_image.paste(resized_logo, (fixed_x_px, fixed_y_px), resized_logo) + + frame.flags.writeable = True + # PIL から numpy に画像を戻す + frame = np.array(pil_image) + # 色の順序をもとに戻す + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + + # WebRTC に渡す + self._video_source.on_captured(frame) + return angle + + +def hideface_sender(): + # .env ファイルを読み込む + load_dotenv() + + # 必須引数 + signaling_urls = os.getenv("SORA_SIGNALING_URLS").split(",") + channel_id = os.getenv("SORA_CHANNEL_ID") + + # オプション引数 + metadata = None + raw_metadata = os.getenv("SORA_METADATA") + if raw_metadata is not None: + metadata = json.loads(raw_metadata) + + camera_id = int(os.getenv("SORA_CAMERA_ID", "0")) + video_width = int(os.getenv("SORA_VIDEO_WIDTH", "640")) + video_height = int(os.getenv("SORA_VIDEO_HEIGHT", "360")) + + streamer = LogoStreamer( + signaling_urls=signaling_urls, + role="sendonly", + channel_id=channel_id, + metadata=metadata, + camera_id=camera_id, + video_height=video_height, + video_width=video_width, + ) + streamer.run() + + +if __name__ == "__main__": + hideface_sender() diff --git a/examples/src/ml/shiguremaru.png b/examples/src/ml/shiguremaru.png new file mode 100644 index 0000000000000000000000000000000000000000..1ac1e541c86db8491ef4615faa13cf8931aefb99 GIT binary patch literal 24990 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 SE%*Tgh?TjWS(Pav=KlaGXr|Zz literal 0 HcmV?d00001 diff --git a/examples/sync.sh b/examples/sync.sh new file mode 100755 index 00000000..6c9c4d77 --- /dev/null +++ b/examples/sync.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# ローカルインストールしたキャッシュを削除した上で rye sync するスクリプト。 +# +# `rye add sora_sdk --path ` でローカルで書き換えた sora-python-sdk を +# 利用可能だが、キャッシュが残っていると一生更新されないため、キャッシュディレクトリを削除する。 +# +# 毎回どこのディレクトリを消せばいいか忘れてしまうので、このスクリプトで対応する。 + +set -ex + +case "`uname`" in + "Darwin" ) CACHE_DIR=~/Library/Caches/pip ;; + "Linux" ) CACHE_DIR=~/.cache/pip ;; + * ) exit 1 ;; +esac + +rm -rf $CACHE_DIR/wheels +rye sync