From e2ab122bf993bef84e70c5bd515ffa8cb23732a1 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Thu, 5 Jun 2025 11:58:56 -0400 Subject: [PATCH 01/18] Update sample demo code to work with ARFlow v0.4 --- python/examples/simple/simple.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/python/examples/simple/simple.py b/python/examples/simple/simple.py index b3326a7d..a7e20f86 100644 --- a/python/examples/simple/simple.py +++ b/python/examples/simple/simple.py @@ -5,19 +5,28 @@ import arflow - class CustomService(arflow.ARFlowServicer): - def on_register(self, request: arflow.RegisterClientRequest): - """Called when a client registers.""" - print("Client registered!") - - def on_frame_received(self, decoded_data_frame: arflow.DecodedDataFrame): - """Called when a frame is received.""" - print("Frame received!") - + def on_save_ar_frames(self, frames, session_stream, device): + print("AR frame received") + def on_save_transform_frames(self, frames, session_stream, device): + print("Transform frame received") + def on_save_color_frames(self, frames, session_stream, device): + print("Color frame received") + def on_save_depth_frames(self, frames, session_stream, device): + print("Depth frame received") + def on_save_gyroscope_frames(self, frames, session_stream, device): + print("Gyroscope frame received") + def on_save_audio_frames(self, frames, session_stream, device): + print("Audio frame received") + def on_save_plane_detection_frames(self, frames, session_stream, device): + print("Plane detection frame received") + def on_save_point_cloud_frames(self, frames, session_stream, device): + print("Point cloud frame received") + def on_save_mesh_detection_frames(self, frames, session_stream, device): + print("Mesh detection frame received") def main(): - arflow.run_server(CustomService, port=8500, save_dir=Path("./")) + arflow.run_server(CustomService, spawn_viewer = True, port=8500) if __name__ == "__main__": From c00afdd545f209fc984899a782342d39a9ab756f Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Fri, 6 Jun 2025 16:01:45 -0400 Subject: [PATCH 02/18] Rewrote the basic functionality of the C# GrpcClient Class -Some nuances are not in this version such as the logger middleware/advanced http options --- python/client/GrpcClient.py | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 python/client/GrpcClient.py diff --git a/python/client/GrpcClient.py b/python/client/GrpcClient.py new file mode 100644 index 00000000..087bce26 --- /dev/null +++ b/python/client/GrpcClient.py @@ -0,0 +1,67 @@ +import grpc +from typing import Awaitable, Iterable + +from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame + +from cakelab.arflow_grpc.v1.arflow_service_pb2_grpc import ARFlowServiceStub +from cakelab.arflow_grpc.v1.session_pb2 import SessionUuid, SessionMetadata +from cakelab.arflow_grpc.v1.device_pb2 import Device + +from cakelab.arflow_grpc.v1.create_session_request_pb2 import CreateSessionRequest +from cakelab.arflow_grpc.v1.create_session_response_pb2 import CreateSessionResponse +from cakelab.arflow_grpc.v1.delete_session_request_pb2 import DeleteSessionRequest +from cakelab.arflow_grpc.v1.delete_session_response_pb2 import DeleteSessionResponse +from cakelab.arflow_grpc.v1.get_session_request_pb2 import GetSessionRequest +from cakelab.arflow_grpc.v1.get_session_response_pb2 import GetSessionResponse +from cakelab.arflow_grpc.v1.join_session_request_pb2 import JoinSessionRequest +from cakelab.arflow_grpc.v1.join_session_response_pb2 import JoinSessionResponse +from cakelab.arflow_grpc.v1.list_sessions_request_pb2 import ListSessionsRequest +from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse +from cakelab.arflow_grpc.v1.save_ar_frames_request_pb2 import SaveARFramesRequest +from cakelab.arflow_grpc.v1.save_ar_frames_response_pb2 import SaveARFramesResponse + +class GrpcClient: + def __init__(self, url): + self.channel = grpc.insecure_channel(url) + async def CreateSessionAsync(self, name: str, device: Device, save_path: str = "") -> CreateSessionResponse: + request = CreateSessionRequest( + session_metadata=SessionMetadata(name=name, save_path=save_path), + device=device + ) + response: Awaitable[CreateSessionResponse] = ARFlowServiceStub(self.channel).CreateSession(request) + return response + async def DeleteSessionAsync(self, session_id: str) -> DeleteSessionResponse: + request = DeleteSessionRequest( + session_id=SessionUuid(value = session_id) + ) + response: Awaitable[DeleteSessionResponse] = ARFlowServiceStub(self.channel).DeleteSession(request) + return response + async def GetSessionAsync(self, session_id: str) -> GetSessionResponse: + request = GetSessionRequest( + session_id=SessionUuid(value=session_id) + ) + response: Awaitable[GetSessionResponse] = ARFlowServiceStub(self.channel).GetSession(request) + return response + async def JoinSessionAsync(self, session_id: str, device: Device) -> JoinSessionResponse: + request = JoinSessionRequest( + session_id=SessionUuid(value=session_id), + device=device + ) + response: Awaitable[JoinSessionResponse] = ARFlowServiceStub(self.channel).JoinSession(request) + return response + async def ListSessionsAsync(self) -> ListSessionsResponse: + request = ListSessionsRequest() + response: Awaitable[ListSessionsResponse] = ARFlowServiceStub(self.channel).ListSessions(request) + return response + async def SaveARFramesAsync(self, session_id: str, ar_frames: Iterable[ARFrame], device: Device) -> SaveARFramesResponse: + request = SaveARFramesRequest( + session_id=SessionUuid(value=session_id), + frames=ar_frames, + device=device + ) + response: Awaitable[SaveARFramesResponse] = ARFlowServiceStub(self.channel).SaveARFrames(request) + return response + + def close(self): + self.channel.close() + \ No newline at end of file From dba4ca332cfeb71082273b52fa09364e19dcbcac Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Fri, 6 Jun 2025 17:34:42 -0400 Subject: [PATCH 03/18] Add in the C# GetDeviceInfo class to make getting device info easier -I am not sure about what model is so I am leaving it as the concatenation of the operating system name and its version -Type needs to be changed later to be assigned propertly, for now only uses 3 as this is for the cli which only runs on desktops --- python/client/util/GetDeviceInfo.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 python/client/util/GetDeviceInfo.py diff --git a/python/client/util/GetDeviceInfo.py b/python/client/util/GetDeviceInfo.py new file mode 100644 index 00000000..d1e2ecd5 --- /dev/null +++ b/python/client/util/GetDeviceInfo.py @@ -0,0 +1,16 @@ +from cakelab.arflow_grpc.v1.device_pb2 import Device +import platform +import uuid + +class GetDeviceInfo: + @staticmethod + def get_device_info() -> Device: + name = platform.node() + #not sure what model is, im just gonna leave it as system name combined with version, change this later + model = platform.system() + platform.version() + return Device( + name=name, + model=model, + type=3, #for now only functions on desktops + uid= str(uuid.uuid3(uuid.NAMESPACE_DNS, name + model)) + ) \ No newline at end of file From cd14cbaf9563b9c2946927ac5bfd2f9b9687e489 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Fri, 6 Jun 2025 18:44:10 -0400 Subject: [PATCH 04/18] Add basic CLI client -Currently unable to gather data (coming soon) -Will not leave sessions (will be implemented with data gathering) --- python/client/clients/cli.py | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 python/client/clients/cli.py diff --git a/python/client/clients/cli.py b/python/client/clients/cli.py new file mode 100644 index 00000000..8a1d894b --- /dev/null +++ b/python/client/clients/cli.py @@ -0,0 +1,83 @@ + + +from GrpcClient import GrpcClient +from util.GetDeviceInfo import GetDeviceInfo +from util.SessionRunner import SessionRunner + +from cakelab.arflow_grpc.v1.device_pb2 import Device +from cakelab.arflow_grpc.v1.session_pb2 import Session + +from cakelab.arflow_grpc.v1.create_session_response_pb2 import CreateSessionResponse +from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse +import asyncio + +class CLIClient: + session: Session | None = None + def __init__(self): + host = input("Enter hostname") + port = input("Enter port") + self.client = GrpcClient(f"{host}:{port}") + asyncio.run(self.__manage_sessions()) + + async def __manage_sessions(self): + while True: + print("Available Sessions:") + session_list: list[Session] = list((await self.client.ListSessionsAsync()).sessions) + for session in session_list: + print(f" Name: {session.metadata.name}, # of Devices: {len(session.devices)}") + print("Options:") + print("1. Create and Join Session") + print("2. Join Session") + print("3. Delete Session") + print("4. Refresh") + print("5. Exit") + choice: str = input("Choose an option: ") + match choice: + case "1": + name: str = input("Enter session name: ") + if any(session.metadata.name == name for session in session_list): + name = input(f"Session with name '{name}' already exists. Please choose a different name:") + device: Device = GetDeviceInfo.get_device_info() + try: + session = (await self.client.CreateSessionAsync(name, device)).session + print("Created Session") + await self.__join_session(session) + except Exception as e: + print(f"Failed to create session: {e}") + case "2": + name: str = input("Enter session name to join: ") + target_session: Session | None = next((session for session in session_list if session.metadata.name == name), None) + if target_session is None: + print(f"No session found with name '{name}'") + continue + try: + session = await self.client.JoinSessionAsync(target_session.id.value, GetDeviceInfo.get_device_info()) + print("Joined Session") + await self.__join_session(session) + except Exception as e: + print(f"Failed to join session: {e}") + case "3": + name: str = input("Enter session name to delete: ") + target_session: Session | None = next((session for session in session_list if session.metadata.name == name), None) + if target_session is None: + print(f"No session found with name '{name}'") + continue + try: + await self.client.DeleteSessionAsync(target_session.id.value) + print(f"Session '{name}' deleted successfully.") + except Exception as e: + print(f"Failed to delete session: {e}") + case "4": + continue + case "5": + return + case _: + print("Invalid Option") + async def __join_session(self, session: Session): + print("Active session logic missing! Implement me!") +def main(): + CLIClient() + + +if __name__ == "__main__": + main() \ No newline at end of file From 59020e6b75619d76e421795bf8777b13a4ab7725 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Fri, 6 Jun 2025 21:27:24 -0400 Subject: [PATCH 05/18] Update Grpc client to use snake_case as it was formatted before to follow CamelCase from the original GrpcClient.cs --- python/client/GrpcClient.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/client/GrpcClient.py b/python/client/GrpcClient.py index 087bce26..5d330c9b 100644 --- a/python/client/GrpcClient.py +++ b/python/client/GrpcClient.py @@ -23,37 +23,37 @@ class GrpcClient: def __init__(self, url): self.channel = grpc.insecure_channel(url) - async def CreateSessionAsync(self, name: str, device: Device, save_path: str = "") -> CreateSessionResponse: + async def create_session_async(self, name: str, device: Device, save_path: str = "") -> CreateSessionResponse: request = CreateSessionRequest( session_metadata=SessionMetadata(name=name, save_path=save_path), device=device ) response: Awaitable[CreateSessionResponse] = ARFlowServiceStub(self.channel).CreateSession(request) return response - async def DeleteSessionAsync(self, session_id: str) -> DeleteSessionResponse: + async def delete_session_async(self, session_id: str) -> DeleteSessionResponse: request = DeleteSessionRequest( session_id=SessionUuid(value = session_id) ) response: Awaitable[DeleteSessionResponse] = ARFlowServiceStub(self.channel).DeleteSession(request) return response - async def GetSessionAsync(self, session_id: str) -> GetSessionResponse: + async def get_session_async(self, session_id: str) -> GetSessionResponse: request = GetSessionRequest( session_id=SessionUuid(value=session_id) ) response: Awaitable[GetSessionResponse] = ARFlowServiceStub(self.channel).GetSession(request) return response - async def JoinSessionAsync(self, session_id: str, device: Device) -> JoinSessionResponse: + async def join_session_async(self, session_id: str, device: Device) -> JoinSessionResponse: request = JoinSessionRequest( session_id=SessionUuid(value=session_id), device=device ) response: Awaitable[JoinSessionResponse] = ARFlowServiceStub(self.channel).JoinSession(request) return response - async def ListSessionsAsync(self) -> ListSessionsResponse: + async def list_sessions_async(self) -> ListSessionsResponse: request = ListSessionsRequest() response: Awaitable[ListSessionsResponse] = ARFlowServiceStub(self.channel).ListSessions(request) return response - async def SaveARFramesAsync(self, session_id: str, ar_frames: Iterable[ARFrame], device: Device) -> SaveARFramesResponse: + async def save_ar_frames_async(self, session_id: str, ar_frames: Iterable[ARFrame], device: Device) -> SaveARFramesResponse: request = SaveARFramesRequest( session_id=SessionUuid(value=session_id), frames=ar_frames, From 4657e4a3b2e56e53bcafc810dab976342841b48f Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Fri, 6 Jun 2025 21:39:23 -0400 Subject: [PATCH 06/18] Forgot to add the leave_session function to this file --- python/client/GrpcClient.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/python/client/GrpcClient.py b/python/client/GrpcClient.py index 5d330c9b..38dd8c62 100644 --- a/python/client/GrpcClient.py +++ b/python/client/GrpcClient.py @@ -17,6 +17,8 @@ from cakelab.arflow_grpc.v1.join_session_response_pb2 import JoinSessionResponse from cakelab.arflow_grpc.v1.list_sessions_request_pb2 import ListSessionsRequest from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse +from cakelab.arflow_grpc.v1.leave_session_request_pb2 import LeaveSessionRequest +from cakelab.arflow_grpc.v1.leave_session_response_pb2 import LeaveSessionResponse from cakelab.arflow_grpc.v1.save_ar_frames_request_pb2 import SaveARFramesRequest from cakelab.arflow_grpc.v1.save_ar_frames_response_pb2 import SaveARFramesResponse @@ -53,6 +55,13 @@ async def list_sessions_async(self) -> ListSessionsResponse: request = ListSessionsRequest() response: Awaitable[ListSessionsResponse] = ARFlowServiceStub(self.channel).ListSessions(request) return response + async def leave_session_async(self, session_id: str, device: Device) -> LeaveSessionResponse: + request = LeaveSessionRequest( + session_id=SessionUuid(value=session_id), + device=device + ) + response: Awaitable[LeaveSessionResponse] = ARFlowServiceStub(self.channel).LeaveSession(request) + return response async def save_ar_frames_async(self, session_id: str, ar_frames: Iterable[ARFrame], device: Device) -> SaveARFramesResponse: request = SaveARFramesRequest( session_id=SessionUuid(value=session_id), From 6cec12e560512cc4f5acddc77b1c0049f6d451a8 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Fri, 6 Jun 2025 21:58:37 -0400 Subject: [PATCH 07/18] Create basic CLI client -Currently only able to collect color frames --- python/client/clients/cli.py | 69 +++++++++++++++++++++++++---- python/client/util/SessionRunner.py | 65 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 python/client/util/SessionRunner.py diff --git a/python/client/clients/cli.py b/python/client/clients/cli.py index 8a1d894b..7a1b51b3 100644 --- a/python/client/clients/cli.py +++ b/python/client/clients/cli.py @@ -6,13 +6,20 @@ from cakelab.arflow_grpc.v1.device_pb2 import Device from cakelab.arflow_grpc.v1.session_pb2 import Session +from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame from cakelab.arflow_grpc.v1.create_session_response_pb2 import CreateSessionResponse from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse + import asyncio +from threading import Thread +from threading import Event +from time import sleep class CLIClient: session: Session | None = None + running: Thread | None = None + stop_event: Event | None = None def __init__(self): host = input("Enter hostname") port = input("Enter port") @@ -22,7 +29,7 @@ def __init__(self): async def __manage_sessions(self): while True: print("Available Sessions:") - session_list: list[Session] = list((await self.client.ListSessionsAsync()).sessions) + session_list: list[Session] = list((await self.client.list_sessions_async()).sessions) for session in session_list: print(f" Name: {session.metadata.name}, # of Devices: {len(session.devices)}") print("Options:") @@ -39,9 +46,9 @@ async def __manage_sessions(self): name = input(f"Session with name '{name}' already exists. Please choose a different name:") device: Device = GetDeviceInfo.get_device_info() try: - session = (await self.client.CreateSessionAsync(name, device)).session + self.session = (await self.client.create_session_async(name, device)).session print("Created Session") - await self.__join_session(session) + await self.__join_session() except Exception as e: print(f"Failed to create session: {e}") case "2": @@ -51,9 +58,9 @@ async def __manage_sessions(self): print(f"No session found with name '{name}'") continue try: - session = await self.client.JoinSessionAsync(target_session.id.value, GetDeviceInfo.get_device_info()) + self.session = (await self.client.join_session_async(target_session.id.value, GetDeviceInfo.get_device_info())).session print("Joined Session") - await self.__join_session(session) + await self.__join_session() except Exception as e: print(f"Failed to join session: {e}") case "3": @@ -63,7 +70,7 @@ async def __manage_sessions(self): print(f"No session found with name '{name}'") continue try: - await self.client.DeleteSessionAsync(target_session.id.value) + await self.client.delete_session_async(target_session.id.value) print(f"Session '{name}' deleted successfully.") except Exception as e: print(f"Failed to delete session: {e}") @@ -73,8 +80,54 @@ async def __manage_sessions(self): return case _: print("Invalid Option") - async def __join_session(self, session: Session): - print("Active session logic missing! Implement me!") + + async def __join_session(self): + if self.session is None: return + runner = SessionRunner(self.session, GetDeviceInfo.get_device_info(), self.__on_frame) + print("Currently only able to record camera frames") + while True: + print("Options:") + if self.running: + print("1. Stop Recording") + else: + print("1. Start Recording") + print("2. Leave Session") + choice: str = input("Choose an option: ") + match choice: + case "1": + if self.running: + self.stop_event.set() + self.running.join() + self.running = None + self.stop_event = None + print("Stopping Recording") + else: + self.stop_event = Event() + self.running = Thread(target=self.__record_frame, args=(runner,)) + self.running.start() + print("Starting Recording") + case "2": + await self.client.leave_session_async(self.session.id.value, GetDeviceInfo.get_device_info()) + print("Leaving Session") + return + case _: + print("Invalid Option") + + def __record_frame(self, runner: SessionRunner): + while not self.stop_event.is_set(): + asyncio.run(runner.gather_camera_frame_async()) + sleep(0.25) + + + async def __on_frame(self, session: Session, frame: ARFrame , device: Device): + if self.session is None: + return + await self.client.save_ar_frames_async( + session_id=self.session.id.value, + ar_frames=[frame], + device=GetDeviceInfo.get_device_info() + ) + def main(): CLIClient() diff --git a/python/client/util/SessionRunner.py b/python/client/util/SessionRunner.py new file mode 100644 index 00000000..756e7cc7 --- /dev/null +++ b/python/client/util/SessionRunner.py @@ -0,0 +1,65 @@ +from cakelab.arflow_grpc.v1.session_pb2 import Session +from cakelab.arflow_grpc.v1.device_pb2 import Device + +from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame +from cakelab.arflow_grpc.v1.color_frame_pb2 import ColorFrame +from cakelab.arflow_grpc.v1.xr_cpu_image_pb2 import XRCpuImage +from cakelab.arflow_grpc.v1.vector2_int_pb2 import Vector2Int +from google.protobuf.timestamp_pb2 import Timestamp +import cv2 +import time +from typing import Callable, Coroutine, Any + +class SessionRunner: + camera : cv2.VideoCapture | None = None + session: Session | None = None + device: Device | None = None + onARFrame: Callable[[Session, ARFrame, Device], Coroutine[Any, Any, None]] | None = None + def __init__(self, session: Session, device: Device, onARFrame: Callable[[Session, ARFrame, Device], Coroutine[Any, Any, None]]): + self.camera = cv2.VideoCapture(0) + self.onARFrame = onARFrame + self.session = session + self.device = device + def __del__(self): + if self.camera is not None: + self.camera.release() + self.camera = None + async def gather_camera_frame_async(self) -> None: + if self.camera is None: + return + ret, frame = self.camera.read() + if not ret: + return + height, width = frame.shape[:2] + yuv = (cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)).flatten() + y_size = width * height + uv_size = y_size // 4 + Y: XRCpuImage.Plane = XRCpuImage.Plane(data = (yuv[:y_size].reshape((height, width))).tobytes(), row_stride = width, pixel_stride=1) + U: XRCpuImage.Plane = XRCpuImage.Plane(data = (yuv[y_size:y_size + uv_size].reshape((height // 2, width // 2))).tobytes(), row_stride = width // 2, pixel_stride=1) + V: XRCpuImage.Plane = XRCpuImage.Plane(data = (yuv[y_size + uv_size:].reshape((height // 2, width // 2))).tobytes(), row_stride = width // 2, pixel_stride=1) + # Trim the U and V planes because ARFlow adds an extra byte as it is a bug with the android format + U.data = U.data[:-1] + V.data = V.data[:-1] + now = time.time() + timestamp = Timestamp() + nanos = int(now * 1e9) + Timestamp.FromNanoseconds(timestamp, nanos) + xrcpu_image: XRCpuImage = XRCpuImage( + dimensions= Vector2Int(x=width, y=height), + format= XRCpuImage.FORMAT_ANDROID_YUV_420_888, + timestamp=now, + planes=[Y, U, V] + ) + color_frame = ColorFrame( + image=xrcpu_image, + device_timestamp=timestamp + ) + ar_frame = ARFrame( + color_frame=color_frame + ) + if self.onARFrame and self.session and self.device: + await self.onARFrame(self.session, ar_frame, self.device) + + + + From 4262e0a5240f0e0fee844e0efe0e9a36ffa7659a Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Fri, 6 Jun 2025 22:08:55 -0400 Subject: [PATCH 08/18] fix formatting on some of the inputs --- python/client/clients/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/client/clients/cli.py b/python/client/clients/cli.py index 7a1b51b3..2a58d163 100644 --- a/python/client/clients/cli.py +++ b/python/client/clients/cli.py @@ -21,8 +21,8 @@ class CLIClient: running: Thread | None = None stop_event: Event | None = None def __init__(self): - host = input("Enter hostname") - port = input("Enter port") + host = input("Enter hostname: ") + port = input("Enter port: ") self.client = GrpcClient(f"{host}:{port}") asyncio.run(self.__manage_sessions()) From e8ed83a1a3248270d85c843ddb0ced4782c7ecfe Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Sun, 8 Jun 2025 14:49:38 -0400 Subject: [PATCH 09/18] fix leaving the session while recording running not ending recording --- python/client/clients/cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/python/client/clients/cli.py b/python/client/clients/cli.py index 2a58d163..65ac1699 100644 --- a/python/client/clients/cli.py +++ b/python/client/clients/cli.py @@ -107,6 +107,11 @@ async def __join_session(self): self.running.start() print("Starting Recording") case "2": + if self.running: + self.stop_event.set() + self.running.join() + self.running = None + self.stop_event = None await self.client.leave_session_async(self.session.id.value, GetDeviceInfo.get_device_info()) print("Leaving Session") return From f9c6bc9dda0265e7604ec57657ccb861ace0930f Mon Sep 17 00:00:00 2001 From: Yiqin Zhao Date: Wed, 11 Jun 2025 18:58:54 -0400 Subject: [PATCH 10/18] new: code style and docstring improvements. --- python/client/GrpcClient.py | 154 +++++++++++++++++++++++++++--------- 1 file changed, 117 insertions(+), 37 deletions(-) diff --git a/python/client/GrpcClient.py b/python/client/GrpcClient.py index 38dd8c62..478c9b0c 100644 --- a/python/client/GrpcClient.py +++ b/python/client/GrpcClient.py @@ -1,76 +1,156 @@ -import grpc +"""A simple Python gRPC client.""" + +# ruff:noqa: D103 +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +# We have to do the above because the grpc stub has no type hints + from typing import Awaitable, Iterable -from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame +import grpc +from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame from cakelab.arflow_grpc.v1.arflow_service_pb2_grpc import ARFlowServiceStub -from cakelab.arflow_grpc.v1.session_pb2 import SessionUuid, SessionMetadata -from cakelab.arflow_grpc.v1.device_pb2 import Device - from cakelab.arflow_grpc.v1.create_session_request_pb2 import CreateSessionRequest from cakelab.arflow_grpc.v1.create_session_response_pb2 import CreateSessionResponse from cakelab.arflow_grpc.v1.delete_session_request_pb2 import DeleteSessionRequest from cakelab.arflow_grpc.v1.delete_session_response_pb2 import DeleteSessionResponse +from cakelab.arflow_grpc.v1.device_pb2 import Device from cakelab.arflow_grpc.v1.get_session_request_pb2 import GetSessionRequest from cakelab.arflow_grpc.v1.get_session_response_pb2 import GetSessionResponse from cakelab.arflow_grpc.v1.join_session_request_pb2 import JoinSessionRequest from cakelab.arflow_grpc.v1.join_session_response_pb2 import JoinSessionResponse -from cakelab.arflow_grpc.v1.list_sessions_request_pb2 import ListSessionsRequest -from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse from cakelab.arflow_grpc.v1.leave_session_request_pb2 import LeaveSessionRequest from cakelab.arflow_grpc.v1.leave_session_response_pb2 import LeaveSessionResponse +from cakelab.arflow_grpc.v1.list_sessions_request_pb2 import ListSessionsRequest +from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse from cakelab.arflow_grpc.v1.save_ar_frames_request_pb2 import SaveARFramesRequest from cakelab.arflow_grpc.v1.save_ar_frames_response_pb2 import SaveARFramesResponse +from cakelab.arflow_grpc.v1.session_pb2 import SessionMetadata, SessionUuid + class GrpcClient: - def __init__(self, url): + """A simple gRPC client class.""" + + stub: ARFlowServiceStub + + def __init__(self, url: str): + """Initialize the gRPC client. + + Args: + url: The URL of the gRPC server. + """ self.channel = grpc.insecure_channel(url) - async def create_session_async(self, name: str, device: Device, save_path: str = "") -> CreateSessionResponse: + self.stub = ARFlowServiceStub(self.channel) + + async def create_session_async( + self, name: str, device: Device, save_path: str = "" + ) -> Awaitable[CreateSessionResponse]: + """Create a new session. + + Args: + name: The name of the session. + device: The device to use for the session. + save_path: The path to save the session data to. + + Returns: + The response from the server. + """ request = CreateSessionRequest( session_metadata=SessionMetadata(name=name, save_path=save_path), - device=device + device=device, ) - response: Awaitable[CreateSessionResponse] = ARFlowServiceStub(self.channel).CreateSession(request) + response: Awaitable[CreateSessionResponse] = self.stub.CreateSession(request) return response - async def delete_session_async(self, session_id: str) -> DeleteSessionResponse: - request = DeleteSessionRequest( - session_id=SessionUuid(value = session_id) - ) - response: Awaitable[DeleteSessionResponse] = ARFlowServiceStub(self.channel).DeleteSession(request) + + async def delete_session_async( + self, session_id: str + ) -> Awaitable[DeleteSessionResponse]: + """Delete a session. + + Args: + session_id: The session id to delete. + + Returns: + The response from the server. + """ + request = DeleteSessionRequest(session_id=SessionUuid(value=session_id)) + response: Awaitable[DeleteSessionResponse] = self.stub.DeleteSession(request) return response - async def get_session_async(self, session_id: str) -> GetSessionResponse: - request = GetSessionRequest( - session_id=SessionUuid(value=session_id) - ) - response: Awaitable[GetSessionResponse] = ARFlowServiceStub(self.channel).GetSession(request) + + async def get_session_async(self, session_id: str) -> Awaitable[GetSessionResponse]: + """Get a session by its ID. + + Args: + session_id: The session id to get. + + Returns: + The response from the server. + """ + request = GetSessionRequest(session_id=SessionUuid(value=session_id)) + response: Awaitable[GetSessionResponse] = self.stub.GetSession(request) return response - async def join_session_async(self, session_id: str, device: Device) -> JoinSessionResponse: + + async def join_session_async( + self, session_id: str, device: Device + ) -> Awaitable[JoinSessionResponse]: + """Join a session. + + Args: + session_id: The session id to join. + device: The device to join the session with. + + Returns: + The response from the server. + """ request = JoinSessionRequest( - session_id=SessionUuid(value=session_id), - device=device + session_id=SessionUuid(value=session_id), device=device ) - response: Awaitable[JoinSessionResponse] = ARFlowServiceStub(self.channel).JoinSession(request) + response: Awaitable[JoinSessionResponse] = self.stub.JoinSession(request) return response - async def list_sessions_async(self) -> ListSessionsResponse: + + async def list_sessions_async(self) -> Awaitable[ListSessionsResponse]: + """List all sessions.""" request = ListSessionsRequest() - response: Awaitable[ListSessionsResponse] = ARFlowServiceStub(self.channel).ListSessions(request) + response: Awaitable[ListSessionsResponse] = self.stub.ListSessions(request) return response - async def leave_session_async(self, session_id: str, device: Device) -> LeaveSessionResponse: + + async def leave_session_async( + self, session_id: str, device: Device + ) -> Awaitable[LeaveSessionResponse]: + """Leave a session. + + Args: + session_id: The session ID. + device: The device that left the session. + + Returns: + The response from the server. + """ request = LeaveSessionRequest( - session_id=SessionUuid(value=session_id), - device=device + session_id=SessionUuid(value=session_id), device=device ) - response: Awaitable[LeaveSessionResponse] = ARFlowServiceStub(self.channel).LeaveSession(request) + response: Awaitable[LeaveSessionResponse] = self.stub.LeaveSession(request) return response - async def save_ar_frames_async(self, session_id: str, ar_frames: Iterable[ARFrame], device: Device) -> SaveARFramesResponse: + + async def save_ar_frames_async( + self, session_id: str, ar_frames: Iterable[ARFrame], device: Device + ) -> Awaitable[SaveARFramesResponse]: + """Save AR frames to the session. + + Args: + session_id: The session ID. + ar_frames: The AR frames to save. + device: The device that captured the AR frames. + + Returns: + The response from the server. + """ request = SaveARFramesRequest( - session_id=SessionUuid(value=session_id), - frames=ar_frames, - device=device + session_id=SessionUuid(value=session_id), frames=ar_frames, device=device ) - response: Awaitable[SaveARFramesResponse] = ARFlowServiceStub(self.channel).SaveARFrames(request) + response: Awaitable[SaveARFramesResponse] = self.stub.SaveARFrames(request) return response def close(self): + """Close the channel.""" self.channel.close() - \ No newline at end of file From 1f7135e2b0d70baacff7e62235829f36314312c7 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Wed, 9 Jul 2025 15:10:21 -0400 Subject: [PATCH 11/18] Add the rgb support from the other branch --- python/arflow/_session_stream.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/arflow/_session_stream.py b/python/arflow/_session_stream.py index d5342dc1..132df190 100644 --- a/python/arflow/_session_stream.py +++ b/python/arflow/_session_stream.py @@ -146,6 +146,20 @@ def save_color_frames( pixel_format=rr.PixelFormat.Y_U_V12_LimitedRange, ) data = np.array([_to_i420_format(f.image) for f in homogenous_frames]) + elif format == XRCpuImage.FORMAT_RGB24: + """ + Decode a frame in RGB format and display it + """ + format_static = rr.components.ImageFormat( + width=width, + height=height, + pixel_format=None, + color_model=rr.ColorModel.RGB, + ) + data = np.array([ + np.frombuffer(f.image.planes[0].data, dtype=np.uint8) + for f in homogenous_frames + ]) # elif format == XRCpuImage.FORMAT_IOS_YP_CBCR_420_8BI_PLANAR_FULL_RANGE: # format_static = rr.components.ImageFormat( # width=width, From d3b518e98e25f01091d5999122e927f4401bda66 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Wed, 9 Jul 2025 15:17:59 -0400 Subject: [PATCH 12/18] add jpg/png support clientside --- python/client/util/SessionRunner.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/python/client/util/SessionRunner.py b/python/client/util/SessionRunner.py index 756e7cc7..cdf76bba 100644 --- a/python/client/util/SessionRunner.py +++ b/python/client/util/SessionRunner.py @@ -31,24 +31,23 @@ async def gather_camera_frame_async(self) -> None: if not ret: return height, width = frame.shape[:2] - yuv = (cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)).flatten() - y_size = width * height - uv_size = y_size // 4 - Y: XRCpuImage.Plane = XRCpuImage.Plane(data = (yuv[:y_size].reshape((height, width))).tobytes(), row_stride = width, pixel_stride=1) - U: XRCpuImage.Plane = XRCpuImage.Plane(data = (yuv[y_size:y_size + uv_size].reshape((height // 2, width // 2))).tobytes(), row_stride = width // 2, pixel_stride=1) - V: XRCpuImage.Plane = XRCpuImage.Plane(data = (yuv[y_size + uv_size:].reshape((height // 2, width // 2))).tobytes(), row_stride = width // 2, pixel_stride=1) - # Trim the U and V planes because ARFlow adds an extra byte as it is a bug with the android format - U.data = U.data[:-1] - V.data = V.data[:-1] + success, encoded = cv2.imencode('.jpg', frame) + if not success: + return + plane: XRCpuImage.Plane = XRCpuImage.Plane( + data=encoded.tobytes(), + row_stride=0, + pixel_stride=0, + ) now = time.time() timestamp = Timestamp() nanos = int(now * 1e9) Timestamp.FromNanoseconds(timestamp, nanos) xrcpu_image: XRCpuImage = XRCpuImage( dimensions= Vector2Int(x=width, y=height), - format= XRCpuImage.FORMAT_ANDROID_YUV_420_888, + format= XRCpuImage.FORMAT_JPEG_RGB24, timestamp=now, - planes=[Y, U, V] + planes=[plane] ) color_frame = ColorFrame( image=xrcpu_image, From 629f7a18f4ee3a72a9d2a713c9a25ed3707249d0 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Wed, 9 Jul 2025 15:22:48 -0400 Subject: [PATCH 13/18] Update protobuffers to have available enum fields --- .../cakelab/arflow_grpc/v1/xr_cpu_image.proto | 12 ++++++---- .../arflow_grpc/v1/xr_cpu_image_pb2.py | 6 ++--- .../arflow_grpc/v1/xr_cpu_image_pb2.pyi | 14 +++++++++++ .../Runtime/Grpc/V1/XrCpuImage.cs | 24 +++++++++++++------ 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/protos/cakelab/arflow_grpc/v1/xr_cpu_image.proto b/protos/cakelab/arflow_grpc/v1/xr_cpu_image.proto index 2bd870d1..75750eee 100644 --- a/protos/cakelab/arflow_grpc/v1/xr_cpu_image.proto +++ b/protos/cakelab/arflow_grpc/v1/xr_cpu_image.proto @@ -17,11 +17,13 @@ message XRCpuImage { // @exclude FORMAT_ONECOMPONENT8 = 3; FORMAT_DEPTHFLOAT32 = 4; /// iOS FORMAT_DEPTHUINT16 = 5; /// Android - // @exclude FORMAT_ONECOMPONENT32 = 6; - // @exclude FORMAT_ARGB32 = 7; - // @exclude FORMAT_RGBA32 = 8; - // @exclude FORMAT_BGRA32 = 9; - // @exclude FORMAT_RGB24 = 10; + FORMAT_ONECOMPONENT32 = 6; + FORMAT_ARGB32 = 7; + FORMAT_RGBA32 = 8; + FORMAT_BGRA32 = 9; + FORMAT_RGB24 = 10; + FORMAT_JPEG_RGB24 = 11; + FORMAT_PNG_RGB24 = 12; } /// https://docs.unity3d.com/Packages/com.unity.xr.arfoundation@6.0/api/UnityEngine.XR.ARSubsystems.XRCpuImage.Plane.html diff --git a/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.py b/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.py index ded7745d..40d0b23e 100644 --- a/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.py +++ b/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.py @@ -25,7 +25,7 @@ from cakelab.arflow_grpc.v1 import vector2_int_pb2 as cakelab_dot_arflow__grpc_dot_v1_dot_vector2__int__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)cakelab/arflow_grpc/v1/xr_cpu_image.proto\x12\x16\x63\x61kelab.arflow_grpc.v1\x1a(cakelab/arflow_grpc/v1/vector2_int.proto\"\xf8\x03\n\nXRCpuImage\x12\x42\n\ndimensions\x18\x01 \x01(\x0b\x32\".cakelab.arflow_grpc.v1.Vector2IntR\ndimensions\x12\x41\n\x06\x66ormat\x18\x02 \x01(\x0e\x32).cakelab.arflow_grpc.v1.XRCpuImage.FormatR\x06\x66ormat\x12\x1c\n\ttimestamp\x18\x03 \x01(\x01R\ttimestamp\x12@\n\x06planes\x18\x04 \x03(\x0b\x32(.cakelab.arflow_grpc.v1.XRCpuImage.PlaneR\x06planes\x1a]\n\x05Plane\x12\x1d\n\nrow_stride\x18\x01 \x01(\x05R\trowStride\x12!\n\x0cpixel_stride\x18\x02 \x01(\x05R\x0bpixelStride\x12\x12\n\x04\x64\x61ta\x18\x03 \x01(\x0cR\x04\x64\x61ta\"\xa3\x01\n\x06\x46ormat\x12\x16\n\x12\x46ORMAT_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x46ORMAT_ANDROID_YUV_420_888\x10\x01\x12\x30\n,FORMAT_IOS_YP_CBCR_420_8BI_PLANAR_FULL_RANGE\x10\x02\x12\x17\n\x13\x46ORMAT_DEPTHFLOAT32\x10\x04\x12\x16\n\x12\x46ORMAT_DEPTHUINT16\x10\x05\x42\xa4\x01\n\x1a\x63om.cakelab.arflow_grpc.v1B\x0fXrCpuImageProtoP\x01\xa2\x02\x03\x43\x41X\xaa\x02\x16\x43\x61keLab.ARFlow.Grpc.V1\xca\x02\x15\x43\x61kelab\\ArflowGrpc\\V1\xe2\x02!Cakelab\\ArflowGrpc\\V1\\GPBMetadata\xea\x02\x17\x43\x61kelab::ArflowGrpc::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)cakelab/arflow_grpc/v1/xr_cpu_image.proto\x12\x16\x63\x61kelab.arflow_grpc.v1\x1a(cakelab/arflow_grpc/v1/vector2_int.proto\"\x8b\x05\n\nXRCpuImage\x12\x42\n\ndimensions\x18\x01 \x01(\x0b\x32\".cakelab.arflow_grpc.v1.Vector2IntR\ndimensions\x12\x41\n\x06\x66ormat\x18\x02 \x01(\x0e\x32).cakelab.arflow_grpc.v1.XRCpuImage.FormatR\x06\x66ormat\x12\x1c\n\ttimestamp\x18\x03 \x01(\x01R\ttimestamp\x12@\n\x06planes\x18\x04 \x03(\x0b\x32(.cakelab.arflow_grpc.v1.XRCpuImage.PlaneR\x06planes\x1a]\n\x05Plane\x12\x1d\n\nrow_stride\x18\x01 \x01(\x05R\trowStride\x12!\n\x0cpixel_stride\x18\x02 \x01(\x05R\x0bpixelStride\x12\x12\n\x04\x64\x61ta\x18\x03 \x01(\x0cR\x04\x64\x61ta\"\xb6\x02\n\x06\x46ormat\x12\x16\n\x12\x46ORMAT_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x46ORMAT_ANDROID_YUV_420_888\x10\x01\x12\x30\n,FORMAT_IOS_YP_CBCR_420_8BI_PLANAR_FULL_RANGE\x10\x02\x12\x17\n\x13\x46ORMAT_DEPTHFLOAT32\x10\x04\x12\x16\n\x12\x46ORMAT_DEPTHUINT16\x10\x05\x12\x19\n\x15\x46ORMAT_ONECOMPONENT32\x10\x06\x12\x11\n\rFORMAT_ARGB32\x10\x07\x12\x11\n\rFORMAT_RGBA32\x10\x08\x12\x11\n\rFORMAT_BGRA32\x10\t\x12\x10\n\x0c\x46ORMAT_RGB24\x10\n\x12\x15\n\x11\x46ORMAT_JPEG_RGB24\x10\x0b\x12\x14\n\x10\x46ORMAT_PNG_RGB24\x10\x0c\x42\xa4\x01\n\x1a\x63om.cakelab.arflow_grpc.v1B\x0fXrCpuImageProtoP\x01\xa2\x02\x03\x43\x41X\xaa\x02\x16\x43\x61keLab.ARFlow.Grpc.V1\xca\x02\x15\x43\x61kelab\\ArflowGrpc\\V1\xe2\x02!Cakelab\\ArflowGrpc\\V1\\GPBMetadata\xea\x02\x17\x43\x61kelab::ArflowGrpc::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,9 +34,9 @@ _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\032com.cakelab.arflow_grpc.v1B\017XrCpuImageProtoP\001\242\002\003CAX\252\002\026CakeLab.ARFlow.Grpc.V1\312\002\025Cakelab\\ArflowGrpc\\V1\342\002!Cakelab\\ArflowGrpc\\V1\\GPBMetadata\352\002\027Cakelab::ArflowGrpc::V1' _globals['_XRCPUIMAGE']._serialized_start=112 - _globals['_XRCPUIMAGE']._serialized_end=616 + _globals['_XRCPUIMAGE']._serialized_end=763 _globals['_XRCPUIMAGE_PLANE']._serialized_start=357 _globals['_XRCPUIMAGE_PLANE']._serialized_end=450 _globals['_XRCPUIMAGE_FORMAT']._serialized_start=453 - _globals['_XRCPUIMAGE_FORMAT']._serialized_end=616 + _globals['_XRCPUIMAGE_FORMAT']._serialized_end=763 # @@protoc_insertion_point(module_scope) diff --git a/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.pyi b/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.pyi index cddb7e6e..a904a23d 100644 --- a/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.pyi +++ b/python/cakelab/arflow_grpc/v1/xr_cpu_image_pb2.pyi @@ -16,11 +16,25 @@ class XRCpuImage(_message.Message): FORMAT_IOS_YP_CBCR_420_8BI_PLANAR_FULL_RANGE: _ClassVar[XRCpuImage.Format] FORMAT_DEPTHFLOAT32: _ClassVar[XRCpuImage.Format] FORMAT_DEPTHUINT16: _ClassVar[XRCpuImage.Format] + FORMAT_ONECOMPONENT32: _ClassVar[XRCpuImage.Format] + FORMAT_ARGB32: _ClassVar[XRCpuImage.Format] + FORMAT_RGBA32: _ClassVar[XRCpuImage.Format] + FORMAT_BGRA32: _ClassVar[XRCpuImage.Format] + FORMAT_RGB24: _ClassVar[XRCpuImage.Format] + FORMAT_JPEG_RGB24: _ClassVar[XRCpuImage.Format] + FORMAT_PNG_RGB24: _ClassVar[XRCpuImage.Format] FORMAT_UNSPECIFIED: XRCpuImage.Format FORMAT_ANDROID_YUV_420_888: XRCpuImage.Format FORMAT_IOS_YP_CBCR_420_8BI_PLANAR_FULL_RANGE: XRCpuImage.Format FORMAT_DEPTHFLOAT32: XRCpuImage.Format FORMAT_DEPTHUINT16: XRCpuImage.Format + FORMAT_ONECOMPONENT32: XRCpuImage.Format + FORMAT_ARGB32: XRCpuImage.Format + FORMAT_RGBA32: XRCpuImage.Format + FORMAT_BGRA32: XRCpuImage.Format + FORMAT_RGB24: XRCpuImage.Format + FORMAT_JPEG_RGB24: XRCpuImage.Format + FORMAT_PNG_RGB24: XRCpuImage.Format class Plane(_message.Message): __slots__ = ("row_stride", "pixel_stride", "data") ROW_STRIDE_FIELD_NUMBER: _ClassVar[int] diff --git a/unity/Packages/edu.wpi.cake.arflow/Runtime/Grpc/V1/XrCpuImage.cs b/unity/Packages/edu.wpi.cake.arflow/Runtime/Grpc/V1/XrCpuImage.cs index df8d456f..e5e543c2 100644 --- a/unity/Packages/edu.wpi.cake.arflow/Runtime/Grpc/V1/XrCpuImage.cs +++ b/unity/Packages/edu.wpi.cake.arflow/Runtime/Grpc/V1/XrCpuImage.cs @@ -26,7 +26,7 @@ static XrCpuImageReflection() { string.Concat( "CiljYWtlbGFiL2FyZmxvd19ncnBjL3YxL3hyX2NwdV9pbWFnZS5wcm90bxIW", "Y2FrZWxhYi5hcmZsb3dfZ3JwYy52MRooY2FrZWxhYi9hcmZsb3dfZ3JwYy92", - "MS92ZWN0b3IyX2ludC5wcm90byL4AwoKWFJDcHVJbWFnZRJCCgpkaW1lbnNp", + "MS92ZWN0b3IyX2ludC5wcm90byKLBQoKWFJDcHVJbWFnZRJCCgpkaW1lbnNp", "b25zGAEgASgLMiIuY2FrZWxhYi5hcmZsb3dfZ3JwYy52MS5WZWN0b3IySW50", "UgpkaW1lbnNpb25zEkEKBmZvcm1hdBgCIAEoDjIpLmNha2VsYWIuYXJmbG93", "X2dycGMudjEuWFJDcHVJbWFnZS5Gb3JtYXRSBmZvcm1hdBIcCgl0aW1lc3Rh", @@ -34,14 +34,17 @@ static XrCpuImageReflection() { "LmFyZmxvd19ncnBjLnYxLlhSQ3B1SW1hZ2UuUGxhbmVSBnBsYW5lcxpdCgVQ", "bGFuZRIdCgpyb3dfc3RyaWRlGAEgASgFUglyb3dTdHJpZGUSIQoMcGl4ZWxf", "c3RyaWRlGAIgASgFUgtwaXhlbFN0cmlkZRISCgRkYXRhGAMgASgMUgRkYXRh", - "IqMBCgZGb3JtYXQSFgoSRk9STUFUX1VOU1BFQ0lGSUVEEAASHgoaRk9STUFU", + "IrYCCgZGb3JtYXQSFgoSRk9STUFUX1VOU1BFQ0lGSUVEEAASHgoaRk9STUFU", "X0FORFJPSURfWVVWXzQyMF84ODgQARIwCixGT1JNQVRfSU9TX1lQX0NCQ1Jf", "NDIwXzhCSV9QTEFOQVJfRlVMTF9SQU5HRRACEhcKE0ZPUk1BVF9ERVBUSEZM", - "T0FUMzIQBBIWChJGT1JNQVRfREVQVEhVSU5UMTYQBUKkAQoaY29tLmNha2Vs", - "YWIuYXJmbG93X2dycGMudjFCD1hyQ3B1SW1hZ2VQcm90b1ABogIDQ0FYqgIW", - "Q2FrZUxhYi5BUkZsb3cuR3JwYy5WMcoCFUNha2VsYWJcQXJmbG93R3JwY1xW", - "MeICIUNha2VsYWJcQXJmbG93R3JwY1xWMVxHUEJNZXRhZGF0YeoCF0Nha2Vs", - "YWI6OkFyZmxvd0dycGM6OlYxYgZwcm90bzM=")); + "T0FUMzIQBBIWChJGT1JNQVRfREVQVEhVSU5UMTYQBRIZChVGT1JNQVRfT05F", + "Q09NUE9ORU5UMzIQBhIRCg1GT1JNQVRfQVJHQjMyEAcSEQoNRk9STUFUX1JH", + "QkEzMhAIEhEKDUZPUk1BVF9CR1JBMzIQCRIQCgxGT1JNQVRfUkdCMjQQChIV", + "ChFGT1JNQVRfSlBFR19SR0IyNBALEhQKEEZPUk1BVF9QTkdfUkdCMjQQDEKk", + "AQoaY29tLmNha2VsYWIuYXJmbG93X2dycGMudjFCD1hyQ3B1SW1hZ2VQcm90", + "b1ABogIDQ0FYqgIWQ2FrZUxhYi5BUkZsb3cuR3JwYy5WMcoCFUNha2VsYWJc", + "QXJmbG93R3JwY1xWMeICIUNha2VsYWJcQXJmbG93R3JwY1xWMVxHUEJNZXRh", + "ZGF0YeoCF0Nha2VsYWI6OkFyZmxvd0dycGM6OlYxYgZwcm90bzM=")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::CakeLab.ARFlow.Grpc.V1.Vector2IntReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { @@ -383,6 +386,13 @@ public enum Format { //// Android /// [pbr::OriginalName("FORMAT_DEPTHUINT16")] Depthuint16 = 5, + [pbr::OriginalName("FORMAT_ONECOMPONENT32")] Onecomponent32 = 6, + [pbr::OriginalName("FORMAT_ARGB32")] Argb32 = 7, + [pbr::OriginalName("FORMAT_RGBA32")] Rgba32 = 8, + [pbr::OriginalName("FORMAT_BGRA32")] Bgra32 = 9, + [pbr::OriginalName("FORMAT_RGB24")] Rgb24 = 10, + [pbr::OriginalName("FORMAT_JPEG_RGB24")] JpegRgb24 = 11, + [pbr::OriginalName("FORMAT_PNG_RGB24")] PngRgb24 = 12, } /// From b9eb139ad884494f435fbc22df2ac9bcb237f70a Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Wed, 9 Jul 2025 15:47:26 -0400 Subject: [PATCH 14/18] add support for png/jpg image transfer capability --- python/arflow/_session_stream.py | 12 ++++++++++++ python/client/util/SessionRunner.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/python/arflow/_session_stream.py b/python/arflow/_session_stream.py index 132df190..8d822f58 100644 --- a/python/arflow/_session_stream.py +++ b/python/arflow/_session_stream.py @@ -7,6 +7,7 @@ import numpy as np import numpy.typing as npt import rerun as rr +import cv2 from arflow._types import ( ARFrameType, @@ -160,6 +161,17 @@ def save_color_frames( np.frombuffer(f.image.planes[0].data, dtype=np.uint8) for f in homogenous_frames ]) + elif format == XRCpuImage.FORMAT_JPEG_RGB24 or format == XRCpuImage.FORMAT_PNG_RGB24: + format_static = rr.components.ImageFormat( + width=width, + height=height, + pixel_format=None, + color_model=rr.ColorModel.BGR, + ) + data = np.array([ + cv2.imdecode(np.frombuffer(f.image.planes[0].data, dtype=np.uint8), cv2.IMREAD_COLOR).flatten() + for f in homogenous_frames + ]) # elif format == XRCpuImage.FORMAT_IOS_YP_CBCR_420_8BI_PLANAR_FULL_RANGE: # format_static = rr.components.ImageFormat( # width=width, diff --git a/python/client/util/SessionRunner.py b/python/client/util/SessionRunner.py index cdf76bba..7b58a7fe 100644 --- a/python/client/util/SessionRunner.py +++ b/python/client/util/SessionRunner.py @@ -45,7 +45,7 @@ async def gather_camera_frame_async(self) -> None: Timestamp.FromNanoseconds(timestamp, nanos) xrcpu_image: XRCpuImage = XRCpuImage( dimensions= Vector2Int(x=width, y=height), - format= XRCpuImage.FORMAT_JPEG_RGB24, + format= XRCpuImage.Format.FORMAT_JPEG_RGB24, timestamp=now, planes=[plane] ) From 2aa005075b40dda34894783b19a2d8df611c2255 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Thu, 10 Jul 2025 00:12:30 -0400 Subject: [PATCH 15/18] add vedant saran's test and modify it to test for frame-by-frame --- python/client/compression_test.py | 457 ++++++++++++++++++ .../real_arflow_test_20250709_235941.csv | 3 + .../real_arflow_test_20250709_235941.json | 45 ++ .../real_arflow_test_20250710_000549.csv | 3 + .../real_arflow_test_20250710_000549.json | 45 ++ .../real_arflow_test_20250710_001054.csv | 3 + .../real_arflow_test_20250710_001054.json | 45 ++ python/client/util/SessionRunner.py | 17 +- 8 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 python/client/compression_test.py create mode 100644 python/client/real_arflow_test_20250709_235941.csv create mode 100644 python/client/real_arflow_test_20250709_235941.json create mode 100644 python/client/real_arflow_test_20250710_000549.csv create mode 100644 python/client/real_arflow_test_20250710_000549.json create mode 100644 python/client/real_arflow_test_20250710_001054.csv create mode 100644 python/client/real_arflow_test_20250710_001054.json diff --git a/python/client/compression_test.py b/python/client/compression_test.py new file mode 100644 index 00000000..d9dbcd3d --- /dev/null +++ b/python/client/compression_test.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Real ARFlow Compression Test + +This script measures the difference between streaming and non-streaming modes +by monitoring existing ARFlow sessions and measuring their network usage. + +Usage: +1. Start ARFlow server: arflow view --port 8500 +2. Connect your device to the server +3. Run this test: python real_arflow_compression_test.py + +Note: This test monitors existing sessions from connected devices. +""" + +import argparse +import asyncio +import csv +import json +import sys +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, List +import traceback + +import cv2 + +# Add ARFlow to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +try: + import psutil # type: ignore + + from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame + from cakelab.arflow_grpc.v1.device_pb2 import Device + from cakelab.arflow_grpc.v1.session_pb2 import Session + from client.GrpcClient import GrpcClient + from client.util.GetDeviceInfo import GetDeviceInfo + from client.util.SessionRunner import SessionRunner +except ImportError as e: + print(f"❌ Failed to import required components: {e}") + print("Make sure you're running this from the python/benchmarks directory") + print("Required: pip install psutil opencv-python") + sys.exit(1) + + +@dataclass +class TestConfig: + """Configuration for a real ARFlow test.""" + + name: str + duration: int # seconds + description: str + width: int + height: int + + +@dataclass +class TestResult: + """Results from a real ARFlow test.""" + + config: TestConfig + frames_sent: int + bytes_sent: int + fps_achieved: float + cpu_usage_avg: float | str + memory_usage_mb: float | str + bandwidth_mbps: float + psnr: float | None + errors: List[str] + width: int + height: int + + +class RealARFlowTester: + """Test compression impact using real ARFlow components.""" + + def __init__(self, server_host: str = "localhost", server_port: int = 8500): + self.server_host = server_host + self.server_port = server_port + self.configs = [ + TestConfig( + name="ARFlow_SD_PNG", + duration=30, + description="ARFlow SD (PNG Compressed)", + width=640, + height=480, + ), + TestConfig( + name="ARFlow_HD_PNG", + duration=30, + description="ARFlow HD (PNG Compressed)", + width=1280, + height=720, + ), + ] + self.results: List[TestResult] = [] + self.client = GrpcClient(f"{self.server_host}:{self.server_port}") + + async def get_existing_session(self) -> tuple[Any, Device]: + """Get an existing session from a connected device.""" + print("πŸ“± Looking for existing sessions...") + + # Create gRPC client + self.client = self.client + + # List existing sessions + response = await self.client.list_sessions_async() + sessions = response.sessions # type: ignore + + + for session in sessions: + if session.id.value == "test_session": + await self.client.delete_session_async("test_session") + session = (await self.client.create_session_async("test_session", GetDeviceInfo.get_device_info(), "")).session + print( + f"βœ… Found session: {session.metadata.name} from device: {session.devices[0].name}" + ) + + # Get device info from the session + device = session.devices[0] + + return session, device + + async def run_test(self, config: TestConfig) -> TestResult: + """Run a single test configuration.""" + print(f"\nπŸ§ͺ Testing: {config.name}") + print(f" Duration: {config.duration}s") + + errors: List[str] = [] + frames_sent = 0 + bytes_sent = 0 + cpu_usage_samples: List[float] = [] + memory_usage_samples: List[float] = [] + + try: + # Get existing session from + session, device = await self.get_existing_session() + + # Track metrics + start_time = time.time() + process = psutil.Process() + initial_memory = process.memory_info().rss / 1024 / 1024 # MB + + # Frame callback to track metrics + async def on_frame(sess: Any, frame: ARFrame, dev: Device) -> None: + nonlocal frames_sent, bytes_sent + frames_sent += 1 + + # Count bytes sent (SessionRunner sends compressed H.264 data) + if frame.color_frame and frame.color_frame.image: + # This will be the actual compressed data size + bytes_sent += len(frame.color_frame.image.planes[0].data) + await self.client.save_ar_frames_async( + session_id=sess.id.value, + ar_frames=[frame], + device=dev + ) + + + # Create session runner to monitor the existing session + print(" Monitoring existing session...") + runner = SessionRunner(session, device, on_frame) + runner.camera.set(cv2.CAP_PROP_FRAME_WIDTH, config.width) + runner.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, config.height) + stopped = False + + # Start recording + async def gather_frames_loop(): + while not stopped: + await runner.gather_camera_frame_async() + await asyncio.sleep(0) # Yield to event loop + + gather_task = asyncio.create_task(gather_frames_loop()) + # Monitor performance + end_time = start_time + config.duration + sample_interval = 1.0 # Sample every second + + while time.time() < end_time: + # Sample CPU and memory + try: + cpu_percent = process.cpu_percent() + memory_mb = process.memory_info().rss / 1024 / 1024 + + cpu_usage_samples.append(cpu_percent) + memory_usage_samples.append(memory_mb) + except Exception as e: + print(f"⚠️ Warning: Failed to sample performance: {e}") + + await asyncio.sleep(sample_interval) + + # Stop the runner + stopped = True + gather_task.cancel() + + # Calculate metrics + actual_duration = time.time() - start_time + fps_achieved = frames_sent / actual_duration if actual_duration > 0 else 0 + cpu_usage_avg = ( + sum(cpu_usage_samples) / len(cpu_usage_samples) + if cpu_usage_samples + else 0 + ) + memory_usage_mb = ( + max(memory_usage_samples) - initial_memory + if memory_usage_samples + else 0 + ) + bandwidth_mbps = ( + (bytes_sent * 8) / (actual_duration * 1_000_000) + if actual_duration > 0 + else 0 + ) + + print(f"βœ… Results:") + print(f" β€’ Frames received: {frames_sent}") + print(f" β€’ Bytes received: {bytes_sent:,}") + print(f" β€’ FPS achieved: {fps_achieved:.2f}") + print(f" β€’ CPU usage: {cpu_usage_avg:.1f}%") + print(f" β€’ Memory usage: {memory_usage_mb:.1f} MB") + print(f" β€’ Bandwidth: {bandwidth_mbps:.2f} Mbps") + + except Exception as e: + error_msg = f"Test failed: {e}" + print(f"❌ {error_msg}") + traceback.print_exc() + errors.append(error_msg) + + # Set default values for failed test + fps_achieved = 0.0 + cpu_usage_avg = 0.0 + memory_usage_mb = 0.0 + bandwidth_mbps = 0.0 + return TestResult( + config=config, + frames_sent=frames_sent, + bytes_sent=bytes_sent, + fps_achieved=fps_achieved, + cpu_usage_avg=cpu_usage_avg, + memory_usage_mb=memory_usage_mb, + bandwidth_mbps=bandwidth_mbps, + psnr=runner.summed_psnr / frames_sent if runner.summed_psnr != None else None, + errors=errors, + width=config.width, + height=config.height, + ) + + async def run_all_tests(self) -> None: + """Run all test configurations.""" + print("πŸš€ Real ARFlow Compression Evaluation") + print("=" * 50) + print(f"Server: {self.server_host}:{self.server_port}") + print(f"Configurations: {len(self.configs)}") + print("=" * 50) + + # Check if server is reachable + print("πŸ” Checking ARFlow server connection...") + try: + client = self.client + # Try to list sessions to test connection + response = await client.list_sessions_async() + sessions = response.sessions # type: ignore + print(f"βœ… Connected to ARFlow server ({len(sessions)} active sessions)") + + except Exception as e: + print(f"❌ Cannot connect to ARFlow server: {e}") + print("Please start the ARFlow server first:") + print(f" arflow view --port {self.server_port}") + return + + # Run tests + for i, config in enumerate(self.configs, 1): + print(f"\n[{i}/{len(self.configs)}]") + result = await self.run_test(config) + self.results.append(result) + + # Brief pause between tests + if i < len(self.configs): + print("⏸️ Pausing between tests...") + await asyncio.sleep(5) + + self.generate_reports() + + def generate_reports(self) -> None: + """Generate detailed reports.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # CSV Report + csv_file = f"real_arflow_test_{timestamp}.csv" + with open(csv_file, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + "Configuration", + "Duration (s)", + "Frames Received", + "Bytes Received", + "FPS Achieved", + "CPU Usage (%)", + "Memory Usage (MB)", + "Bandwidth (Mbps)", + "PSNR (dB)", + "Errors", + ] + ) + for r in self.results: + writer.writerow( + [ + r.config.name, + r.config.duration, + r.frames_sent, + r.bytes_sent, + f"{r.fps_achieved:.2f}", + f"{r.cpu_usage_avg:.1f}", + f"{r.memory_usage_mb:.1f}", + f"{r.bandwidth_mbps:.2f}", + f"{r.psnr:.2f}" if r.psnr != None else "N/A", + "; ".join(r.errors) if r.errors else "None", + ] + ) + + # JSON Report + json_file = f"real_arflow_test_{timestamp}.json" + with open(json_file, "w") as f: + json.dump( + { + "timestamp": timestamp, + "test_type": "real_arflow_compression", + "server": f"{self.server_host}:{self.server_port}", + "results": [ + { + "config": { + "name": r.config.name, + "duration": r.config.duration, + "description": r.config.description, + "width": r.config.width, + "height": r.config.height, + }, + "measurements": { + "frames_sent": r.frames_sent, + "bytes_sent": r.bytes_sent, + "fps_achieved": r.fps_achieved, + "cpu_usage_avg": r.cpu_usage_avg, + "memory_usage_mb": r.memory_usage_mb, + "bandwidth_mbps": r.bandwidth_mbps, + "psnr": r.psnr, + "errors": r.errors, + }, + } + for r in self.results + ], + }, + f, + indent=2, + ) + + print(f"\nπŸ“Š Real ARFlow Test Complete!") + print(f"πŸ“„ Reports: {csv_file}, {json_file}") + + self.print_analysis() + + def print_analysis(self) -> None: + """Print analysis of the results.""" + print(f"\nπŸ“ˆ Real ARFlow Performance Analysis:") + print("-" * 60) + + if self.results and not self.results[0].errors: + result = self.results[0] + + print(f"\nARFlow Streaming Performance:") + print(f" β€’ Actual FPS: {result.fps_achieved:.2f}") + print(f" β€’ Bandwidth usage: {result.bandwidth_mbps:.2f} Mbps") + print(f" β€’ CPU usage: {result.cpu_usage_avg:.1f}%") + print(f" β€’ Memory usage: {result.memory_usage_mb:.1f} MB") + print(f" β€’ Frames processed: {result.frames_sent}") + print( + f" β€’ Average bytes per frame: {result.bytes_sent // result.frames_sent if result.frames_sent > 0 else 0:,}" + ) + + # Estimate uncompressed equivalent + estimated_width, estimated_height = 640, 480 + uncompressed_bytes_per_frame = estimated_width * estimated_height * 3 # RGB + total_uncompressed_bytes = uncompressed_bytes_per_frame * result.frames_sent + compression_ratio = ( + total_uncompressed_bytes / result.bytes_sent + if result.bytes_sent > 0 + else 0 + ) + + print(f"\nCompression Efficiency:") + print(f" β€’ Estimated compression ratio: {compression_ratio:.1f}:1") + print( + f" β€’ Bandwidth savings: {((total_uncompressed_bytes - result.bytes_sent) / total_uncompressed_bytes) * 100:.1f}%" + ) + print( + f" β€’ Uncompressed equivalent: {(total_uncompressed_bytes * 8) / (result.config.duration * 1_000_000):.2f} Mbps" + ) + else: + print("\n⚠️ Could not analyze results due to test failures") + + +async def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Test real ARFlow compression performance" + ) + parser.add_argument( + "--duration", + type=int, + default=30, + help="Test duration in seconds (default: 30)", + ) + parser.add_argument( + "--host", + type=str, + default="localhost", + help="ARFlow server host (default: localhost)", + ) + parser.add_argument( + "--port", + type=int, + default=8500, + help="ARFlow server port (default: 8500)", + ) + + args = parser.parse_args() + + # Check dependencies + try: + import cv2 # type: ignore + + print("βœ… OpenCV available") + except ImportError: + print("❌ OpenCV not found. Install with: pip install opencv-python") + print("This is required for camera capture in SessionRunner") + sys.exit(1) + + try: + import psutil # type: ignore + + print("βœ… psutil available") + except ImportError: + print("❌ psutil not found. Install with: pip install psutil") + print("This is required for performance monitoring") + sys.exit(1) + + # Update test durations + tester = RealARFlowTester(args.host, args.port) + for config in tester.configs: + config.duration = args.duration + + await tester.run_all_tests() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/python/client/real_arflow_test_20250709_235941.csv b/python/client/real_arflow_test_20250709_235941.csv new file mode 100644 index 00000000..114f706b --- /dev/null +++ b/python/client/real_arflow_test_20250709_235941.csv @@ -0,0 +1,3 @@ +Configuration,Duration (s),Frames Received,Bytes Received,FPS Achieved,CPU Usage (%),Memory Usage (MB),Bandwidth (Mbps),PSNR (dB),Errors +ARFlow_SD_JPG,30,790,40084790,26.29,17.0,72.0,10.67,45.44,None +ARFlow_HD_JPG,30,823,104385101,26.61,41.1,49.7,27.00,46.59,None diff --git a/python/client/real_arflow_test_20250709_235941.json b/python/client/real_arflow_test_20250709_235941.json new file mode 100644 index 00000000..4ddd5332 --- /dev/null +++ b/python/client/real_arflow_test_20250709_235941.json @@ -0,0 +1,45 @@ +{ + "timestamp": "20250709_235941", + "test_type": "real_arflow_compression", + "server": "localhost:8500", + "results": [ + { + "config": { + "name": "ARFlow_SD_JPG", + "duration": 30, + "description": "ARFlow SD (JPG Compressed)", + "width": 640, + "height": 480 + }, + "measurements": { + "frames_sent": 790, + "bytes_sent": 40084790, + "fps_achieved": 26.287684824391214, + "cpu_usage_avg": 17.028, + "memory_usage_mb": 71.9609375, + "bandwidth_mbps": 10.670747602753506, + "psnr": 45.44057593172643, + "errors": [] + } + }, + { + "config": { + "name": "ARFlow_HD_JPG", + "duration": 30, + "description": "ARFlow HD (JPG Compressed)", + "width": 1280, + "height": 720 + }, + "measurements": { + "frames_sent": 823, + "bytes_sent": 104385101, + "fps_achieved": 26.612517645600366, + "cpu_usage_avg": 41.11538461538461, + "memory_usage_mb": 49.734375, + "bandwidth_mbps": 27.00316250109625, + "psnr": 46.586260293895094, + "errors": [] + } + } + ] +} \ No newline at end of file diff --git a/python/client/real_arflow_test_20250710_000549.csv b/python/client/real_arflow_test_20250710_000549.csv new file mode 100644 index 00000000..cf9c5867 --- /dev/null +++ b/python/client/real_arflow_test_20250710_000549.csv @@ -0,0 +1,3 @@ +Configuration,Duration (s),Frames Received,Bytes Received,FPS Achieved,CPU Usage (%),Memory Usage (MB),Bandwidth (Mbps),PSNR (dB),Errors +ARFlow_SD_RAW,30,443,408268800,14.35,13.8,79.3,105.82,N/A,None +ARFlow_HD_RAW,30,154,425779200,4.97,14.5,70.8,109.99,N/A,None diff --git a/python/client/real_arflow_test_20250710_000549.json b/python/client/real_arflow_test_20250710_000549.json new file mode 100644 index 00000000..1928ba59 --- /dev/null +++ b/python/client/real_arflow_test_20250710_000549.json @@ -0,0 +1,45 @@ +{ + "timestamp": "20250710_000549", + "test_type": "real_arflow_compression", + "server": "localhost:8500", + "results": [ + { + "config": { + "name": "ARFlow_SD_RAW", + "duration": 30, + "description": "ARFlow SD (RAW)", + "width": 640, + "height": 480 + }, + "measurements": { + "frames_sent": 443, + "bytes_sent": 408268800, + "fps_achieved": 14.352133811610944, + "cpu_usage_avg": 13.833333333333334, + "memory_usage_mb": 79.28515625, + "bandwidth_mbps": 105.81541216624517, + "psnr": null, + "errors": [] + } + }, + { + "config": { + "name": "ARFlow_HD_RAW", + "duration": 30, + "description": "ARFlow HD (RAW)", + "width": 1280, + "height": 720 + }, + "measurements": { + "frames_sent": 154, + "bytes_sent": 425779200, + "fps_achieved": 4.972587614010481, + "cpu_usage_avg": 14.526315789473685, + "memory_usage_mb": 70.84765625, + "bandwidth_mbps": 109.98568188172943, + "psnr": null, + "errors": [] + } + } + ] +} \ No newline at end of file diff --git a/python/client/real_arflow_test_20250710_001054.csv b/python/client/real_arflow_test_20250710_001054.csv new file mode 100644 index 00000000..eb250a7e --- /dev/null +++ b/python/client/real_arflow_test_20250710_001054.csv @@ -0,0 +1,3 @@ +Configuration,Duration (s),Frames Received,Bytes Received,FPS Achieved,CPU Usage (%),Memory Usage (MB),Bandwidth (Mbps),PSNR (dB),Errors +ARFlow_SD_PNG,30,611,192035334,19.84,46.4,81.9,49.89,361.20,None +ARFlow_HD_PNG,30,217,185273703,7.10,49.4,66.9,48.47,361.20,None diff --git a/python/client/real_arflow_test_20250710_001054.json b/python/client/real_arflow_test_20250710_001054.json new file mode 100644 index 00000000..39a73ed1 --- /dev/null +++ b/python/client/real_arflow_test_20250710_001054.json @@ -0,0 +1,45 @@ +{ + "timestamp": "20250710_001054", + "test_type": "real_arflow_compression", + "server": "localhost:8500", + "results": [ + { + "config": { + "name": "ARFlow_SD_PNG", + "duration": 30, + "description": "ARFlow SD (PNG Compressed)", + "width": 640, + "height": 480 + }, + "measurements": { + "frames_sent": 611, + "bytes_sent": 192035334, + "fps_achieved": 19.842161486386413, + "cpu_usage_avg": 46.36, + "memory_usage_mb": 81.91796875, + "bandwidth_mbps": 49.890620076204925, + "psnr": 361.2019990992245, + "errors": [] + } + }, + { + "config": { + "name": "ARFlow_HD_PNG", + "duration": 30, + "description": "ARFlow HD (PNG Compressed)", + "width": 1280, + "height": 720 + }, + "measurements": { + "frames_sent": 217, + "bytes_sent": 185273703, + "fps_achieved": 7.096268537299018, + "cpu_usage_avg": 49.42857142857143, + "memory_usage_mb": 66.921875, + "bandwidth_mbps": 48.470117949780004, + "psnr": 361.20199909922064, + "errors": [] + } + } + ] +} \ No newline at end of file diff --git a/python/client/util/SessionRunner.py b/python/client/util/SessionRunner.py index 7b58a7fe..c8e902f2 100644 --- a/python/client/util/SessionRunner.py +++ b/python/client/util/SessionRunner.py @@ -14,6 +14,8 @@ class SessionRunner: camera : cv2.VideoCapture | None = None session: Session | None = None device: Device | None = None + frames_gathered: int = 0 + summed_psnr: float = 0.0 onARFrame: Callable[[Session, ARFrame, Device], Coroutine[Any, Any, None]] | None = None def __init__(self, session: Session, device: Device, onARFrame: Callable[[Session, ARFrame, Device], Coroutine[Any, Any, None]]): self.camera = cv2.VideoCapture(0) @@ -31,24 +33,35 @@ async def gather_camera_frame_async(self) -> None: if not ret: return height, width = frame.shape[:2] - success, encoded = cv2.imencode('.jpg', frame) + + success, encoded = cv2.imencode('.png', frame) if not success: return + self.summed_psnr += cv2.PSNR(frame, cv2.imdecode(encoded, cv2.IMREAD_COLOR)) plane: XRCpuImage.Plane = XRCpuImage.Plane( data=encoded.tobytes(), row_stride=0, pixel_stride=0, ) + """ + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + plane: XRCpuImage.Plane = XRCpuImage.Plane( + data=frame_rgb.tobytes(), + row_stride=3 * width, + pixel_stride=3, + ) + """ now = time.time() timestamp = Timestamp() nanos = int(now * 1e9) Timestamp.FromNanoseconds(timestamp, nanos) xrcpu_image: XRCpuImage = XRCpuImage( dimensions= Vector2Int(x=width, y=height), - format= XRCpuImage.Format.FORMAT_JPEG_RGB24, + format= XRCpuImage.Format.FORMAT_PNG_RGB24, timestamp=now, planes=[plane] ) + color_frame = ColorFrame( image=xrcpu_image, device_timestamp=timestamp From 26370168346bb32f977acd9c5b237600be975dfd Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Thu, 10 Jul 2025 00:20:30 -0400 Subject: [PATCH 16/18] fix ruff's checks --- python/arflow/_session_stream.py | 2 +- python/client/GrpcClient.py | 25 ++++++++++++++++++------- python/client/clients/cli.py | 16 +++++++++------- python/client/compression_test.py | 16 +++++----------- python/client/util/GetDeviceInfo.py | 7 ++++++- python/client/util/SessionRunner.py | 21 ++++++++++++++------- 6 files changed, 53 insertions(+), 34 deletions(-) diff --git a/python/arflow/_session_stream.py b/python/arflow/_session_stream.py index 8d822f58..25bcf34f 100644 --- a/python/arflow/_session_stream.py +++ b/python/arflow/_session_stream.py @@ -3,11 +3,11 @@ import logging from collections.abc import Sequence +import cv2 import DracoPy import numpy as np import numpy.typing as npt import rerun as rr -import cv2 from arflow._types import ( ARFrameType, diff --git a/python/client/GrpcClient.py b/python/client/GrpcClient.py index 38dd8c62..c91d8bd2 100644 --- a/python/client/GrpcClient.py +++ b/python/client/GrpcClient.py @@ -1,31 +1,35 @@ -import grpc +"""A gRPC client for interacting with the ARFlow server.""" from typing import Awaitable, Iterable -from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame +import grpc +from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame from cakelab.arflow_grpc.v1.arflow_service_pb2_grpc import ARFlowServiceStub -from cakelab.arflow_grpc.v1.session_pb2 import SessionUuid, SessionMetadata -from cakelab.arflow_grpc.v1.device_pb2 import Device - from cakelab.arflow_grpc.v1.create_session_request_pb2 import CreateSessionRequest from cakelab.arflow_grpc.v1.create_session_response_pb2 import CreateSessionResponse from cakelab.arflow_grpc.v1.delete_session_request_pb2 import DeleteSessionRequest from cakelab.arflow_grpc.v1.delete_session_response_pb2 import DeleteSessionResponse +from cakelab.arflow_grpc.v1.device_pb2 import Device from cakelab.arflow_grpc.v1.get_session_request_pb2 import GetSessionRequest from cakelab.arflow_grpc.v1.get_session_response_pb2 import GetSessionResponse from cakelab.arflow_grpc.v1.join_session_request_pb2 import JoinSessionRequest from cakelab.arflow_grpc.v1.join_session_response_pb2 import JoinSessionResponse -from cakelab.arflow_grpc.v1.list_sessions_request_pb2 import ListSessionsRequest -from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse from cakelab.arflow_grpc.v1.leave_session_request_pb2 import LeaveSessionRequest from cakelab.arflow_grpc.v1.leave_session_response_pb2 import LeaveSessionResponse +from cakelab.arflow_grpc.v1.list_sessions_request_pb2 import ListSessionsRequest +from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse from cakelab.arflow_grpc.v1.save_ar_frames_request_pb2 import SaveARFramesRequest from cakelab.arflow_grpc.v1.save_ar_frames_response_pb2 import SaveARFramesResponse +from cakelab.arflow_grpc.v1.session_pb2 import SessionMetadata, SessionUuid + class GrpcClient: + """A gRPC client for interacting with the ARFlow server.""" def __init__(self, url): + """Initializes a new GrpcClient instance.""" self.channel = grpc.insecure_channel(url) async def create_session_async(self, name: str, device: Device, save_path: str = "") -> CreateSessionResponse: + """Creates a new session.""" request = CreateSessionRequest( session_metadata=SessionMetadata(name=name, save_path=save_path), device=device @@ -33,18 +37,21 @@ async def create_session_async(self, name: str, device: Device, save_path: str = response: Awaitable[CreateSessionResponse] = ARFlowServiceStub(self.channel).CreateSession(request) return response async def delete_session_async(self, session_id: str) -> DeleteSessionResponse: + """Deletes a specific session.""" request = DeleteSessionRequest( session_id=SessionUuid(value = session_id) ) response: Awaitable[DeleteSessionResponse] = ARFlowServiceStub(self.channel).DeleteSession(request) return response async def get_session_async(self, session_id: str) -> GetSessionResponse: + """Retrieves a specific session.""" request = GetSessionRequest( session_id=SessionUuid(value=session_id) ) response: Awaitable[GetSessionResponse] = ARFlowServiceStub(self.channel).GetSession(request) return response async def join_session_async(self, session_id: str, device: Device) -> JoinSessionResponse: + """Joins an existing session.""" request = JoinSessionRequest( session_id=SessionUuid(value=session_id), device=device @@ -52,10 +59,12 @@ async def join_session_async(self, session_id: str, device: Device) -> JoinSessi response: Awaitable[JoinSessionResponse] = ARFlowServiceStub(self.channel).JoinSession(request) return response async def list_sessions_async(self) -> ListSessionsResponse: + """Lists all active sessions.""" request = ListSessionsRequest() response: Awaitable[ListSessionsResponse] = ARFlowServiceStub(self.channel).ListSessions(request) return response async def leave_session_async(self, session_id: str, device: Device) -> LeaveSessionResponse: + """Leaves a session.""" request = LeaveSessionRequest( session_id=SessionUuid(value=session_id), device=device @@ -63,6 +72,7 @@ async def leave_session_async(self, session_id: str, device: Device) -> LeaveSes response: Awaitable[LeaveSessionResponse] = ARFlowServiceStub(self.channel).LeaveSession(request) return response async def save_ar_frames_async(self, session_id: str, ar_frames: Iterable[ARFrame], device: Device) -> SaveARFramesResponse: + """Saves AR frames to the server.""" request = SaveARFramesRequest( session_id=SessionUuid(value=session_id), frames=ar_frames, @@ -72,5 +82,6 @@ async def save_ar_frames_async(self, session_id: str, ar_frames: Iterable[ARFram return response def close(self): + """Close the gRPC channel.""" self.channel.close() \ No newline at end of file diff --git a/python/client/clients/cli.py b/python/client/clients/cli.py index 65ac1699..549bf4ed 100644 --- a/python/client/clients/cli.py +++ b/python/client/clients/cli.py @@ -1,26 +1,27 @@ +"""A simple command line interface for the ARFlow gRPC server.""" +import asyncio +from threading import Event, Thread +from time import sleep from GrpcClient import GrpcClient from util.GetDeviceInfo import GetDeviceInfo from util.SessionRunner import SessionRunner -from cakelab.arflow_grpc.v1.device_pb2 import Device -from cakelab.arflow_grpc.v1.session_pb2 import Session from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame - from cakelab.arflow_grpc.v1.create_session_response_pb2 import CreateSessionResponse +from cakelab.arflow_grpc.v1.device_pb2 import Device from cakelab.arflow_grpc.v1.list_sessions_response_pb2 import ListSessionsResponse +from cakelab.arflow_grpc.v1.session_pb2 import Session -import asyncio -from threading import Thread -from threading import Event -from time import sleep class CLIClient: + """A simple command line interface for the ARFlow gRPC server.""" session: Session | None = None running: Thread | None = None stop_event: Event | None = None def __init__(self): + """Initialize the CLI client and start the session management loop.""" host = input("Enter hostname: ") port = input("Enter port: ") self.client = GrpcClient(f"{host}:{port}") @@ -134,6 +135,7 @@ async def __on_frame(self, session: Session, frame: ARFrame , device: Device): ) def main(): + """Main function to run the CLI client.""" CLIClient() diff --git a/python/client/compression_test.py b/python/client/compression_test.py index d9dbcd3d..f3e12d4f 100644 --- a/python/client/compression_test.py +++ b/python/client/compression_test.py @@ -1,15 +1,8 @@ #!/usr/bin/env python3 -"""Real ARFlow Compression Test +"""Real ARFlow Compression Test. -This script measures the difference between streaming and non-streaming modes -by monitoring existing ARFlow sessions and measuring their network usage. - -Usage: -1. Start ARFlow server: arflow view --port 8500 -2. Connect your device to the server -3. Run this test: python real_arflow_compression_test.py - -Note: This test monitors existing sessions from connected devices. +This script runs a test for a suite of tests on the device by connecting to an existing ARFlow server +and creating a session. It then starts recording on its own. """ import argparse @@ -18,11 +11,11 @@ import json import sys import time +import traceback from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, List -import traceback import cv2 @@ -77,6 +70,7 @@ class RealARFlowTester: """Test compression impact using real ARFlow components.""" def __init__(self, server_host: str = "localhost", server_port: int = 8500): + """Initialize the tester with server details.""" self.server_host = server_host self.server_port = server_port self.configs = [ diff --git a/python/client/util/GetDeviceInfo.py b/python/client/util/GetDeviceInfo.py index d1e2ecd5..113e29de 100644 --- a/python/client/util/GetDeviceInfo.py +++ b/python/client/util/GetDeviceInfo.py @@ -1,10 +1,15 @@ -from cakelab.arflow_grpc.v1.device_pb2 import Device +"""A class that gets the device information for the current device.""" import platform import uuid +from cakelab.arflow_grpc.v1.device_pb2 import Device + + class GetDeviceInfo: + """A class that gets the device information for the current device.""" @staticmethod def get_device_info() -> Device: + """Gets the device information for the current device.""" name = platform.node() #not sure what model is, im just gonna leave it as system name combined with version, change this later model = platform.system() + platform.version() diff --git a/python/client/util/SessionRunner.py b/python/client/util/SessionRunner.py index c8e902f2..4c4928ab 100644 --- a/python/client/util/SessionRunner.py +++ b/python/client/util/SessionRunner.py @@ -1,16 +1,20 @@ -from cakelab.arflow_grpc.v1.session_pb2 import Session -from cakelab.arflow_grpc.v1.device_pb2 import Device +"""A class that handles the session and camera for a device.""" +import time +from typing import Any, Callable, Coroutine + +import cv2 +from google.protobuf.timestamp_pb2 import Timestamp from cakelab.arflow_grpc.v1.ar_frame_pb2 import ARFrame from cakelab.arflow_grpc.v1.color_frame_pb2 import ColorFrame -from cakelab.arflow_grpc.v1.xr_cpu_image_pb2 import XRCpuImage +from cakelab.arflow_grpc.v1.device_pb2 import Device +from cakelab.arflow_grpc.v1.session_pb2 import Session from cakelab.arflow_grpc.v1.vector2_int_pb2 import Vector2Int -from google.protobuf.timestamp_pb2 import Timestamp -import cv2 -import time -from typing import Callable, Coroutine, Any +from cakelab.arflow_grpc.v1.xr_cpu_image_pb2 import XRCpuImage + class SessionRunner: + """A class that handles the session and camera for a device.""" camera : cv2.VideoCapture | None = None session: Session | None = None device: Device | None = None @@ -18,15 +22,18 @@ class SessionRunner: summed_psnr: float = 0.0 onARFrame: Callable[[Session, ARFrame, Device], Coroutine[Any, Any, None]] | None = None def __init__(self, session: Session, device: Device, onARFrame: Callable[[Session, ARFrame, Device], Coroutine[Any, Any, None]]): + """Initializes a new SessionRunner instance.""" self.camera = cv2.VideoCapture(0) self.onARFrame = onARFrame self.session = session self.device = device def __del__(self): + """Destructor to make sure the camera is released.""" if self.camera is not None: self.camera.release() self.camera = None async def gather_camera_frame_async(self) -> None: + """Gathers a camera frame and triggers an event when its done.""" if self.camera is None: return ret, frame = self.camera.read() From ee354553cdb7c40801c59eeabf5d2ef0420a7a33 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Thu, 10 Jul 2025 00:23:01 -0400 Subject: [PATCH 17/18] readd the correctly commented grpcclient code --- python/client/GrpcClient.py | 153 ++++++++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 42 deletions(-) diff --git a/python/client/GrpcClient.py b/python/client/GrpcClient.py index c91d8bd2..81c8dae3 100644 --- a/python/client/GrpcClient.py +++ b/python/client/GrpcClient.py @@ -1,4 +1,9 @@ -"""A gRPC client for interacting with the ARFlow server.""" +"""A simple Python gRPC client.""" + +# ruff:noqa: D103 +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +# We have to do the above because the grpc stub has no type hints + from typing import Awaitable, Iterable import grpc @@ -24,64 +29,128 @@ class GrpcClient: - """A gRPC client for interacting with the ARFlow server.""" - def __init__(self, url): - """Initializes a new GrpcClient instance.""" + """A simple gRPC client class.""" + + stub: ARFlowServiceStub + + def __init__(self, url: str): + """Initialize the gRPC client. + + Args: + url: The URL of the gRPC server. + """ self.channel = grpc.insecure_channel(url) - async def create_session_async(self, name: str, device: Device, save_path: str = "") -> CreateSessionResponse: - """Creates a new session.""" + self.stub = ARFlowServiceStub(self.channel) + + async def create_session_async( + self, name: str, device: Device, save_path: str = "" + ) -> Awaitable[CreateSessionResponse]: + """Create a new session. + + Args: + name: The name of the session. + device: The device to use for the session. + save_path: The path to save the session data to. + + Returns: + The response from the server. + """ request = CreateSessionRequest( session_metadata=SessionMetadata(name=name, save_path=save_path), - device=device + device=device, ) - response: Awaitable[CreateSessionResponse] = ARFlowServiceStub(self.channel).CreateSession(request) + response: Awaitable[CreateSessionResponse] = self.stub.CreateSession(request) return response - async def delete_session_async(self, session_id: str) -> DeleteSessionResponse: - """Deletes a specific session.""" - request = DeleteSessionRequest( - session_id=SessionUuid(value = session_id) - ) - response: Awaitable[DeleteSessionResponse] = ARFlowServiceStub(self.channel).DeleteSession(request) + + async def delete_session_async( + self, session_id: str + ) -> Awaitable[DeleteSessionResponse]: + """Delete a session. + + Args: + session_id: The session id to delete. + + Returns: + The response from the server. + """ + request = DeleteSessionRequest(session_id=SessionUuid(value=session_id)) + response: Awaitable[DeleteSessionResponse] = self.stub.DeleteSession(request) return response - async def get_session_async(self, session_id: str) -> GetSessionResponse: - """Retrieves a specific session.""" - request = GetSessionRequest( - session_id=SessionUuid(value=session_id) - ) - response: Awaitable[GetSessionResponse] = ARFlowServiceStub(self.channel).GetSession(request) + + async def get_session_async(self, session_id: str) -> Awaitable[GetSessionResponse]: + """Get a session by its ID. + + Args: + session_id: The session id to get. + + Returns: + The response from the server. + """ + request = GetSessionRequest(session_id=SessionUuid(value=session_id)) + response: Awaitable[GetSessionResponse] = self.stub.GetSession(request) return response - async def join_session_async(self, session_id: str, device: Device) -> JoinSessionResponse: - """Joins an existing session.""" + + async def join_session_async( + self, session_id: str, device: Device + ) -> Awaitable[JoinSessionResponse]: + """Join a session. + + Args: + session_id: The session id to join. + device: The device to join the session with. + + Returns: + The response from the server. + """ request = JoinSessionRequest( - session_id=SessionUuid(value=session_id), - device=device + session_id=SessionUuid(value=session_id), device=device ) - response: Awaitable[JoinSessionResponse] = ARFlowServiceStub(self.channel).JoinSession(request) + response: Awaitable[JoinSessionResponse] = self.stub.JoinSession(request) return response - async def list_sessions_async(self) -> ListSessionsResponse: - """Lists all active sessions.""" + + async def list_sessions_async(self) -> Awaitable[ListSessionsResponse]: + """List all sessions.""" request = ListSessionsRequest() - response: Awaitable[ListSessionsResponse] = ARFlowServiceStub(self.channel).ListSessions(request) + response: Awaitable[ListSessionsResponse] = self.stub.ListSessions(request) return response - async def leave_session_async(self, session_id: str, device: Device) -> LeaveSessionResponse: - """Leaves a session.""" + + async def leave_session_async( + self, session_id: str, device: Device + ) -> Awaitable[LeaveSessionResponse]: + """Leave a session. + + Args: + session_id: The session ID. + device: The device that left the session. + + Returns: + The response from the server. + """ request = LeaveSessionRequest( - session_id=SessionUuid(value=session_id), - device=device + session_id=SessionUuid(value=session_id), device=device ) - response: Awaitable[LeaveSessionResponse] = ARFlowServiceStub(self.channel).LeaveSession(request) + response: Awaitable[LeaveSessionResponse] = self.stub.LeaveSession(request) return response - async def save_ar_frames_async(self, session_id: str, ar_frames: Iterable[ARFrame], device: Device) -> SaveARFramesResponse: - """Saves AR frames to the server.""" + + async def save_ar_frames_async( + self, session_id: str, ar_frames: Iterable[ARFrame], device: Device + ) -> Awaitable[SaveARFramesResponse]: + """Save AR frames to the session. + + Args: + session_id: The session ID. + ar_frames: The AR frames to save. + device: The device that captured the AR frames. + + Returns: + The response from the server. + """ request = SaveARFramesRequest( - session_id=SessionUuid(value=session_id), - frames=ar_frames, - device=device + session_id=SessionUuid(value=session_id), frames=ar_frames, device=device ) - response: Awaitable[SaveARFramesResponse] = ARFlowServiceStub(self.channel).SaveARFrames(request) + response: Awaitable[SaveARFramesResponse] = self.stub.SaveARFrames(request) return response def close(self): - """Close the gRPC channel.""" - self.channel.close() - \ No newline at end of file + """Close the channel.""" + self.channel.close() \ No newline at end of file From 510f478f27e6a6fe170b273942a77abd4be2bd99 Mon Sep 17 00:00:00 2001 From: Nikita Ostapenko Date: Thu, 10 Jul 2025 20:46:17 -0400 Subject: [PATCH 18/18] Finish up frame by frame compression cleanup small ruff formatting issue --- python/client/GrpcClient.py | 1 - python/client/clients/cli.py | 2 +- python/client/compression_test.py | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/client/GrpcClient.py b/python/client/GrpcClient.py index 6457ec5f..478c9b0c 100644 --- a/python/client/GrpcClient.py +++ b/python/client/GrpcClient.py @@ -153,5 +153,4 @@ async def save_ar_frames_async( def close(self): """Close the channel.""" - self.channel.close() diff --git a/python/client/clients/cli.py b/python/client/clients/cli.py index 549bf4ed..dd719bb3 100644 --- a/python/client/clients/cli.py +++ b/python/client/clients/cli.py @@ -1,4 +1,4 @@ - +# ruff: noqa: T201 """A simple command line interface for the ARFlow gRPC server.""" import asyncio from threading import Event, Thread diff --git a/python/client/compression_test.py b/python/client/compression_test.py index f3e12d4f..a6a05abc 100644 --- a/python/client/compression_test.py +++ b/python/client/compression_test.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# ruff: noqa: T201 """Real ARFlow Compression Test. This script runs a test for a suite of tests on the device by connecting to an existing ARFlow server