diff --git a/src-python/docs/modules/overlay.md b/src-python/docs/modules/overlay.md index 18b65321..648cbdcf 100644 --- a/src-python/docs/modules/overlay.md +++ b/src-python/docs/modules/overlay.md @@ -3,7 +3,7 @@ 目的: OpenVR を使ったオーバーレイ表示(複数サイズ: small/large)を管理する `Overlay` クラスを提供します。 主要メソッド: -- __init__(self, settings_dict) +- __init__(self, settings_dict: dict) - init(self) -> None - startOverlay(self) -> None - shutdownOverlay(self) -> None @@ -18,6 +18,34 @@ - OpenVR (SteamVR) が稼働していることが前提です。`checkSteamvrRunning()` で `vrmonitor.exe` の存在チェックを行います。 - 例外が発生した場合は `errorLogging()` を呼んでスタックトレースを残します。 +短い使用例: + +```py +from models.overlay.overlay_image import OverlayImage +from models.overlay.overlay import Overlay +from PIL import Image + +settings = { + "small": { + "x_pos": 0.0, "y_pos": 0.0, "z_pos": 0.0, + "x_rotation": 0.0, "y_rotation": 0.0, "z_rotation": 0.0, + "display_duration": 5, "fadeout_duration": 2, + "opacity": 1.0, "ui_scaling": 1.0, "tracker": "HMD" + } +} + +overlay_img = OverlayImage() +overlay = Overlay(settings) +overlay.startOverlay() + +# wait until initialized +while not overlay.initialized: + time.sleep(0.5) + +# push a simple blank image +overlay.updateImage(Image.new("RGBA", (256, 64), (255,255,255,255)), "small") +``` + ## モジュール構成(補足) - overlay.py — OpenVR を使ったオーバーレイ管理。Overlay クラスは複数サイズ(small/large)を扱い、位置/回転/透明度/フェードを制御する。 diff --git a/src-python/models/overlay/overlay.py b/src-python/models/overlay/overlay.py index 92c24ab9..4305e77d 100644 --- a/src-python/models/overlay/overlay.py +++ b/src-python/models/overlay/overlay.py @@ -3,6 +3,8 @@ import ctypes import time from psutil import process_iter from threading import Thread +from typing import Any, Dict, Optional, Sequence + import openvr import numpy as np from PIL import Image @@ -18,14 +20,26 @@ try: except ImportError: import overlay_utils as utils -def mat34Id(array): +def mat34Id(array: Sequence[Sequence[float]]) -> Any: + """Convert a 3x4 nested sequence into an openvr.HmdMatrix34_t instance. + + Args: + array: 3x4 numeric sequence + + Returns: + openvr HmdMatrix34_t compatible object + """ arr = openvr.HmdMatrix34_t() for i in range(3): for j in range(4): arr[i][j] = array[i][j] return arr -def getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation): +def getBaseMatrix(x_pos: float, y_pos: float, z_pos: float, x_rotation: float, y_rotation: float, z_rotation: float) -> np.ndarray: + """Create a 3x4 base matrix for an overlay given position and Euler rotations. + + Returns a numpy array of shape (3,4). + """ arr = np.zeros((3, 4)) rot = utils.euler_to_rotation_matrix((x_rotation, y_rotation, z_rotation)) @@ -38,7 +52,7 @@ def getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation): arr[2][3] = - z_pos return arr -def getHMDBaseMatrix(): +def getHMDBaseMatrix() -> np.ndarray: x_pos = 0.0 y_pos = -0.4 z_pos = 1.0 @@ -48,7 +62,7 @@ def getHMDBaseMatrix(): arr = getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation) return arr -def getLeftHandBaseMatrix(): +def getLeftHandBaseMatrix() -> np.ndarray: x_pos = 0.3 y_pos = 0.1 z_pos = -0.31 @@ -58,7 +72,7 @@ def getLeftHandBaseMatrix(): arr = getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation) return arr -def getRightHandBaseMatrix(): +def getRightHandBaseMatrix() -> np.ndarray: x_pos = -0.3 y_pos = 0.1 z_pos = -0.31 @@ -69,24 +83,25 @@ def getRightHandBaseMatrix(): return arr class Overlay: - def __init__(self, settings_dict): - self.system = None - self.overlay = None - self.handle = None - self.init_process = False - self.initialized = False - self.loop = False - self.thread_overlay = None + """Manage OpenVR overlays for multiple sizes (e.g. 'small'/'large').""" + def __init__(self, settings_dict: Dict[str, Dict[str, Any]]) -> None: + self.system: Optional[Any] = None + self.overlay: Optional[Any] = None + self.handle: Dict[str, Any] = {} + self.init_process: bool = False + self.initialized: bool = False + self.loop: bool = False + self.thread_overlay: Optional[Thread] = None - self.settings = {} - self.lastUpdate = {} - self.fadeRatio = {} + self.settings: Dict[str, Dict[str, Any]] = {} + self.lastUpdate: Dict[str, float] = {} + self.fadeRatio: Dict[str, float] = {} for key, value in settings_dict.items(): self.settings[key] = value self.lastUpdate[key] = time.monotonic() - self.fadeRatio[key] = 1 + self.fadeRatio[key] = 1.0 - def init(self): + def init(self) -> None: try: self.system = openvr.init(openvr.VRApplication_Background) self.overlay = openvr.IVROverlay() @@ -119,7 +134,7 @@ class Overlay: except Exception: errorLogging() - def updateImage(self, img, size): + def updateImage(self, img: Image.Image, size: str) -> None: if self.initialized is True: width, height = img.size img = img.tobytes() @@ -139,7 +154,7 @@ class Overlay: self.updateOpacity(self.settings[size]["opacity"], size) self.lastUpdate[size] = time.monotonic() - def clearImage(self, size): + def clearImage(self, size: str) -> None: if self.initialized is True: self.updateImage(Image.new("RGBA", (1, 1), (0, 0, 0, 0)), size) @@ -151,7 +166,7 @@ class Overlay: r, g, b = col self.overlay.setOverlayColor(self.handle[size], r, g, b) - def updateOpacity(self, opacity, size, with_fade=False): + def updateOpacity(self, opacity: float, size: str, with_fade: bool = False) -> None: self.settings[size]["opacity"] = opacity if self.initialized is True: @@ -161,12 +176,12 @@ class Overlay: else: self.overlay.setOverlayAlpha(self.handle[size], self.settings[size]["opacity"]) - def updateUiScaling(self, ui_scaling, size): + def updateUiScaling(self, ui_scaling: float, size: str) -> None: self.settings[size]["ui_scaling"] = ui_scaling if self.initialized is True: self.overlay.setOverlayWidthInMeters(self.handle[size], self.settings[size]["ui_scaling"]) - def updatePosition(self, x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation, tracker, size): + def updatePosition(self, x_pos: float, y_pos: float, z_pos: float, x_rotation: float, y_rotation: float, z_rotation: float, tracker: str, size: str) -> None: """ x_pos, y_pos, z_pos are floats representing the position of overlay x_rotation, y_rotation, z_rotation are floats representing the rotation of overlay @@ -208,13 +223,13 @@ class Overlay: transform ) - def updateDisplayDuration(self, display_duration, size): + def updateDisplayDuration(self, display_duration: float, size: str) -> None: self.settings[size]["display_duration"] = display_duration - def updateFadeoutDuration(self, fadeout_duration, size): + def updateFadeoutDuration(self, fadeout_duration: float, size: str) -> None: self.settings[size]["fadeout_duration"] = fadeout_duration - def checkActive(self): + def checkActive(self) -> bool: try: if self.system is not None and self.initialized is True: new_event = openvr.VREvent_t() @@ -226,7 +241,7 @@ class Overlay: errorLogging() return False - def evaluateOpacityFade(self, size): + def evaluateOpacityFade(self, size: str) -> None: currentTime = time.monotonic() if (currentTime - self.lastUpdate[size]) > self.settings[size]["display_duration"]: timeThroughInterval = currentTime - self.lastUpdate[size] - self.settings[size]["display_duration"] @@ -235,13 +250,13 @@ class Overlay: self.fadeRatio[size] = 0 self.overlay.setOverlayAlpha(self.handle[size], self.fadeRatio[size] * self.settings[size]["opacity"]) - def update(self, size): + def update(self, size: str) -> None: if self.settings[size]["fadeout_duration"] != 0: self.evaluateOpacityFade(size) else: self.updateOpacity(self.settings[size]["opacity"], size) - def mainloop(self): + def mainloop(self) -> None: self.loop = True while self.checkActive() is True and self.loop is True: startTime = time.monotonic() @@ -251,21 +266,21 @@ class Overlay: if sleepTime > 0: time.sleep(sleepTime) - def main(self): + def main(self) -> None: while self.checkSteamvrRunning() is False: time.sleep(10) self.init() if self.initialized is True: self.mainloop() - def startOverlay(self): + def startOverlay(self) -> None: if self.initialized is False and self.init_process is False: self.init_process = True self.thread_overlay = Thread(target=self.main) self.thread_overlay.daemon = True self.thread_overlay.start() - def shutdownOverlay(self): + def shutdownOverlay(self) -> None: if self.initialized is True and self.init_process is False: if isinstance(self.thread_overlay, Thread): self.loop = False @@ -281,7 +296,7 @@ class Overlay: self.system = None self.initialized = False - def reStartOverlay(self): + def reStartOverlay(self) -> None: self.shutdownOverlay() self.startOverlay() diff --git a/src-python/models/overlay/overlay_image.py b/src-python/models/overlay/overlay_image.py index 708ad11c..21520278 100644 --- a/src-python/models/overlay/overlay_image.py +++ b/src-python/models/overlay/overlay_image.py @@ -1,6 +1,6 @@ from os import path as os_path from datetime import datetime -from typing import Tuple +from typing import Tuple, List, Optional from PIL import Image, ImageDraw, ImageFont try: from utils import errorLogging @@ -18,8 +18,14 @@ class OverlayImage: "Chinese Traditional": "NotoSansTC-Regular.ttf", } - def __init__(self, root_path: str=None): - self.message_log = [] + def __init__(self, root_path: Optional[str] = None) -> None: + """Overlay image helper. + + Args: + root_path: optional project root to resolve bundled fonts. If omitted, + defaults to repository `fonts` directory. + """ + self.message_log: List[dict] = [] if root_path is None: self.root_path = os_path.join(os_path.dirname(__file__), "..", "..", "..", "fonts") else: @@ -58,7 +64,7 @@ class OverlayImage: } return colors - def createTextboxSmallLog(self, text:str, language:str, text_color:tuple, base_width:int, base_height:int, font_size:int) -> Image: + def createTextboxSmallLog(self, text: str, language: str, text_color: Tuple[int, int, int], base_width: int, base_height: int, font_size: int) -> Image: font_family = self.LANGUAGES.get(language, self.LANGUAGES["Default"]) img = Image.new("RGBA", (base_width, base_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) @@ -92,7 +98,7 @@ class OverlayImage: draw.text((text_x, text_y), text, text_color, anchor="mm", stroke_width=0, font=font, align="center") return img - def createOverlayImageSmallLog(self, message: str, your_language: str, translation: list = [], target_language: list = []) -> Image: + def createOverlayImageSmallLog(self, message: str, your_language: str, translation: List[str] = [], target_language: List[str] = []) -> Image: # UI設定を取得 ui_size = self.getUiSizeSmallLog() width, height, font_size = ui_size["width"], ui_size["height"], ui_size["font_size"] @@ -162,7 +168,7 @@ class OverlayImage: "text_color_time": (120, 120, 120) } - def createTextImageLargeLog(self, message_type:str, size:str, text:str, language:str) -> Image: + def createTextImageLargeLog(self, message_type: str, size: str, text: str, language: str) -> Image: ui_size = self.getUiSizeLargeLog() font_size = ui_size["font_size_large"] if size == "large" else ui_size["font_size_small"] text_color = self.getUiColorLargeLog()[f"text_color_{size}"] @@ -200,7 +206,7 @@ class OverlayImage: draw.multiline_text((text_x, text_y), text, text_color, anchor=anchor, stroke_width=0, font=font, align=align) return img - def createTextImageMessageType(self, message_type:str, date_time:str) -> Image: + def createTextImageMessageType(self, message_type: str, date_time: str) -> Image: ui_size = self.getUiSizeLargeLog() font_size = ui_size["font_size_small"] ui_padding = ui_size["padding"] @@ -242,7 +248,7 @@ class OverlayImage: draw.text((text_x, text_y), text, text_color, anchor=anchor, stroke_width=0, font=font) return img - def createTextboxLargeLog(self, message_type: str, message: str = None, your_language: str = None, translation: list = [], target_language: list = [], date_time: str = None) -> Image: + def createTextboxLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = [], date_time: Optional[str] = None) -> Image: # テキスト画像のリストを作成 images = [self.createTextImageMessageType(message_type, date_time)] @@ -272,7 +278,7 @@ class OverlayImage: return combined_img - def createOverlayImageLargeLog(self, message_type:str, message:str=None, your_language:str=None, translation:list=[], target_language:list=[]) -> Image: + def createOverlayImageLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = []) -> Image: ui_color = self.getUiColorLargeLog() background_color = ui_color["background_color"] background_outline_color = ui_color["background_outline_color"] diff --git a/src-python/models/overlay/overlay_utils.py b/src-python/models/overlay/overlay_utils.py index 0a379dd0..8807d638 100644 --- a/src-python/models/overlay/overlay_utils.py +++ b/src-python/models/overlay/overlay_utils.py @@ -1,11 +1,29 @@ import numpy as np +from typing import Sequence -def toHomogeneous(matrix): + +def toHomogeneous(matrix: np.ndarray) -> np.ndarray: + """Convert a 3x4 base matrix to a 4x4 homogeneous matrix. + + Args: + matrix: 3x4 numpy array + + Returns: + 4x4 numpy array with last row [0, 0, 0, 1] + """ homogeneous_matrix = np.vstack([matrix, [0, 0, 0, 1]]) return homogeneous_matrix # 移動行列を生成する関数 -def calcTranslationMatrix(translation): +def calcTranslationMatrix(translation: Sequence[float]) -> np.ndarray: + """Create a 4x4 translation matrix from a 3-element translation. + + Args: + translation: (tx, ty, tz) + + Returns: + 4x4 numpy translation matrix + """ tx, ty, tz = translation return np.array([ [1, 0, 0, tx], @@ -15,9 +33,10 @@ def calcTranslationMatrix(translation): ]) # X軸周りの回転行列を生成する関数 -def calcRotationMatrixX(angle): - c = np.cos(np.pi/180*angle) - s = np.sin(np.pi/180*angle) +def calcRotationMatrixX(angle: float) -> np.ndarray: + """Rotation matrix around X axis for given angle in degrees.""" + c = np.cos(np.pi / 180 * angle) + s = np.sin(np.pi / 180 * angle) return np.array([ [1, 0, 0, 0], [0, c, -s, 0], @@ -26,9 +45,10 @@ def calcRotationMatrixX(angle): ]) # Y軸周りの回転行列を生成する関数 -def calcRotationMatrixY(angle): - c = np.cos(np.pi/180*angle) - s = np.sin(np.pi/180*angle) +def calcRotationMatrixY(angle: float) -> np.ndarray: + """Rotation matrix around Y axis for given angle in degrees.""" + c = np.cos(np.pi / 180 * angle) + s = np.sin(np.pi / 180 * angle) return np.array([ [c, 0, s, 0], [0, 1, 0, 0], @@ -37,9 +57,10 @@ def calcRotationMatrixY(angle): ]) # Z軸周りの回転行列を生成する関数 -def calcRotationMatrixZ(angle): - c = np.cos(np.pi/180*angle) - s = np.sin(np.pi/180*angle) +def calcRotationMatrixZ(angle: float) -> np.ndarray: + """Rotation matrix around Z axis for given angle in degrees.""" + c = np.cos(np.pi / 180 * angle) + s = np.sin(np.pi / 180 * angle) return np.array([ [c, -s, 0, 0], [s, c, 0, 0], @@ -48,7 +69,17 @@ def calcRotationMatrixZ(angle): ]) # 3x4行列の座標を基準として回転や移動を行う関数 -def transform_matrix(base_matrix, translation, rotation): +def transform_matrix(base_matrix: np.ndarray, translation: Sequence[float], rotation: Sequence[float]) -> np.ndarray: + """Apply translation and Euler rotations to a 3x4 base matrix. + + Args: + base_matrix: 3x4 base transform matrix + translation: (tx, ty, tz) + rotation: (x_deg, y_deg, z_deg) + + Returns: + Transformed 3x4 matrix (numpy.ndarray) + """ homogeneous_base_matrix = toHomogeneous(base_matrix) translation_matrix = calcTranslationMatrix(translation) rotation_matrix_x = calcRotationMatrixX(rotation[0]) @@ -60,10 +91,18 @@ def transform_matrix(base_matrix, translation, rotation): result_matrix = np.dot(homogeneous_base_matrix, transformation_matrix) return result_matrix[:3, :] -def euler_to_rotation_matrix(angles): +def euler_to_rotation_matrix(angles: Sequence[float]) -> np.ndarray: + """Convert Euler angles in degrees to a 3x3 rotation matrix. + + Args: + angles: (x_deg, y_deg, z_deg) + + Returns: + 3x3 rotation matrix + """ phi = angles[0] * np.pi / 180 theta = angles[1] * np.pi / 180 - psi = angles[2]* np.pi / 180 + psi = angles[2] * np.pi / 180 R_x = np.array([[1, 0, 0], [0, np.cos(phi), -np.sin(phi)], [0, np.sin(phi), np.cos(phi)]]) diff --git a/src-python/tests/test_overlay_imports.py b/src-python/tests/test_overlay_imports.py new file mode 100644 index 00000000..b90389e7 --- /dev/null +++ b/src-python/tests/test_overlay_imports.py @@ -0,0 +1,30 @@ +import sys +import time +from PIL import Image + +sys.path.append(r"d:\WORKSPACE\WORK\VRChatProject\VRCT\src-python") + +from models.overlay import overlay_image, overlay_utils + + +def test_overlay_image_create(): + oi = overlay_image.OverlayImage() + img = oi.createOverlayImageSmallLog("hello", "English", [], []) + assert isinstance(img, Image.Image) + + +def test_utils_transform(): + import numpy as np + base = np.array([ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0] + ]) + res = overlay_utils.transform_matrix(base, (0, 0, 0), (0, 0, 0)) + assert res.shape == (3, 4) + + +if __name__ == '__main__': + test_overlay_image_create() + test_utils_transform() + print('tests passed')