diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b0ac1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# top level +.DS_Store +.pytest_cache +.venv +module.tar.gz +viam-module-env +*.AppImage + +# src +src/__pycache__ + +# unit tests +tests/__pycache__ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..53088e1 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# Makefile +IMAGE_NAME = appimage-builder-viam-python-example +CONTAINER_NAME = appimage-builder-viam-python-example +AARCH64_APPIMAGE_NAME = python-appimage-example--aarch64.AppImage + +# Developing +default: + @echo No make target specified. + +# Packaging +build: appimage-aarch64 + +non-appimage: clean # builds tarball from source that runs using venv + tar -czf module.tar.gz run.sh requirements.txt src + +appimage-aarch64: clean + docker build -f packaging/Dockerfile -t $(IMAGE_NAME) . + docker run --name $(CONTAINER_NAME) $(IMAGE_NAME) + docker cp $(CONTAINER_NAME):/app/$(AARCH64_APPIMAGE_NAME) ./$(AARCH64_APPIMAGE_NAME) + chmod +x ${AARCH64_APPIMAGE_NAME} + tar -czf module.tar.gz run.sh $(AARCH64_APPIMAGE_NAME) + +clean: + rm -f $(AARCH64_APPIMAGE_NAME) + rm -f module.tar.gz + docker container stop $(CONTAINER_NAME) || true + docker container rm $(CONTAINER_NAME) || true diff --git a/README.md b/README.md index e10b949..d8e6315 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# python-appimage-module -Example repo for bundling a Viam module using AppImage and AppImageBuilder +# python-appimage-example +Example repo for bundling a Viam module as an AppImage with AppImageBuilder diff --git a/meta.json b/meta.json new file mode 100644 index 0000000..2f26593 --- /dev/null +++ b/meta.json @@ -0,0 +1,13 @@ +{ + "module_id": "viam:python-appimage-example", + "visibility": "public", + "url": "https://github.com/viamrobotics/python-appimage-example", + "description": "Example of deploying a Python module with AppImageBuilder.", + "models": [ + { + "api": "rdk:component:camera", + "model": "viam:camera:oak-d" + } + ], + "entrypoint": "run.sh" +} \ No newline at end of file diff --git a/packaging/AppImageBuilder.yml b/packaging/AppImageBuilder.yml new file mode 100644 index 0000000..97b298d --- /dev/null +++ b/packaging/AppImageBuilder.yml @@ -0,0 +1,32 @@ +version: 1 +script: + - python3 -m pip install -r requirements.txt # install dependency packages as modules in root (modules in this context means Python modules) + - mkdir -p AppDir/usr/lib/python3.10 && cp -r /usr/local/lib/python3.10/site-packages AppDir/usr/lib/python3.10 # cp from root into appdir; site-packages contains all our modules including dependencies + - cp -r src AppDir/usr/lib/python3.10/site-packages # add source code dir to site-packages so the OAK-D module can be discovered in PYTHONPATH and be run as a Python module + - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps/ && cp viam-server.png AppDir/usr/share/icons/hicolor/256x256/apps/ # icon is required + +AppDir: + path: ./AppDir + app_info: + id: com.example-org.python-appimage-example # replace with your own id and name + name: python-appimage-example # affects the outputted .AppImage name + version: "" # affects the outputted .AppImage name + icon: viam-server + exec: usr/bin/python3 + exec_args: "-m src.main $@" + apt: + arch: arm64 + allow_unauthenticated: true + sources: + - sourceline: 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted universe multiverse' + include: + - python3.10 + - python3-pkg-resources # necessary for package metadata and paths + + runtime: + env: + PYTHONHOME: '${APPDIR}/usr' # https://docs.python.org/3/using/cmdline.html#environment-variables + PYTHONPATH: '${APPDIR}/usr/lib/python3.10/site-packages' + +AppImage: + arch: aarch64 # affects the outputted .AppImage name diff --git a/packaging/Dockerfile b/packaging/Dockerfile new file mode 100755 index 0000000..da16a26 --- /dev/null +++ b/packaging/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-bookworm + +RUN apt-get update && \ + apt-get install -y \ + build-essential \ + python3-pip \ + libgtk-3-bin \ + squashfs-tools \ + libglib2.0-bin \ + fakeroot + +# Credit to perrito666 for the ubuntu fix +RUN python3 -m pip install git+https://github.com/hexbabe/appimage-builder.git + +WORKDIR /app + +COPY . /app + +COPY packaging/. /app + +CMD ["appimage-builder", "--recipe", "packaging/AppImageBuilder.yml"] diff --git a/packaging/viam-server.png b/packaging/viam-server.png new file mode 100644 index 0000000..d502e30 Binary files /dev/null and b/packaging/viam-server.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1cfcabf --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +viam-sdk \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..b51acbc --- /dev/null +++ b/run.sh @@ -0,0 +1,43 @@ +#!/bin/sh +cd "$(dirname "$0")" +LOG_PREFIX="[Viam REPLACE_WITH_MODULE_NAME module setup]" + +echo "$LOG_PREFIX Starting the module." + +os=$(uname -s) +arch=$(uname -m) +appimage_path="./python-appimage-example--aarch64.AppImage" +# Run appimage if Linux aarch64 +if [ "$os" = "Linux" ] && [ "$arch" = "aarch64" ] && [ -f "$appimage_path" ]; then + echo "$LOG_PREFIX Detected system Linux AArch64 and appimage. Attempting to start appimage." + chmod +x "$appimage_path" + exec "$appimage_path" "$@" +else + echo "$LOG_PREFIX No usable appimage was found." +fi + +# Else, try running with a virtual environment and source +VENV_NAME="viam-module-env" +PYTHON="$VENV_NAME/bin/python" + +echo "$LOG_PREFIX Running the module using virtual environment. This requires Python >=3.8.1, pip3, and venv to be installed." + +if ! python3 -m venv "$VENV_NAME" >/dev/null 2>&1; then + echo "$LOG_PREFIX Error: failed to create venv. Please use your system package manager to install python3-venv." >&2 + exit 1 +else + echo "$LOG_PREFIX Created/found venv." +fi + +# Remove -U if viam-sdk should not be upgraded whenever possible +# -qq suppresses extraneous output from pip +echo "$LOG_PREFIX Installing/upgrading Python packages." +if ! "$PYTHON" -m pip install -r requirements.txt -Uqq; then + echo "$LOG_PREFIX Error: pip failed to install requirements.txt. Please use your system package manager to install python3-pip." >&2 + exit 1 +fi + +# Be sure to use `exec` so that termination signals reach the python process, +# or handle forwarding termination signals manually +echo "$LOG_PREFIX Starting module." +exec "$PYTHON" -m src.main "$@" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..c28a2e9 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,14 @@ +""" +This file registers the model with the Python SDK. +""" + +from viam.components.camera import Camera +from viam.resource.registry import Registry, ResourceCreatorRegistration + +from src.module import MyModule + +Registry.register_resource_creator( + Camera.SUBTYPE, + MyModule.MODEL, + ResourceCreatorRegistration(MyModule.new, MyModule.validate), +) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..171ab13 --- /dev/null +++ b/src/main.py @@ -0,0 +1,22 @@ +import asyncio + +from viam.components.camera import Camera +from viam.logging import getLogger +from viam.module.module import Module +from src.module import MyModule + + +LOGGER = getLogger(__name__) + + +async def main(): + """This function creates and starts a new module, after adding all desired resources. + Resources must be pre-registered. For an example, see the `__init__.py` file. + """ + module = Module.from_args() + module.add_model_from_registry(Camera.SUBTYPE, MyModule.MODEL) + await module.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/module.py b/src/module.py new file mode 100644 index 0000000..a2253b5 --- /dev/null +++ b/src/module.py @@ -0,0 +1,122 @@ +from typing import Any, ClassVar, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union +from typing_extensions import Self + +from PIL.Image import Image + +from viam.components.camera import Camera, DistortionParameters, IntrinsicParameters, RawImage +from viam.logging import getLogger +from viam.media.video import NamedImage +from viam.module.types import Reconfigurable +from viam.proto.app.robot import ComponentConfig +from viam.proto.common import ResourceName, ResponseMetadata +from viam.resource.base import ResourceBase +from viam.resource.types import Model, ModelFamily + + +LOGGER = getLogger(__name__) + + +class MyModule(Camera, Reconfigurable): # use a better name than this + """ + Camera represents any physical hardware that can capture frames. + """ + class Properties(NamedTuple): + """The camera's supported features and settings""" + supports_pcd: bool + """Whether the camera has a valid implementation of ``get_point_cloud``""" + intrinsic_parameters: IntrinsicParameters + """The properties of the camera""" + distortion_parameters: DistortionParameters + """The distortion parameters of the camera""" + + + MODEL: ClassVar[Model] = Model(ModelFamily("viam", "camera"), "oak-d") # make sure this matches the model in meta.json + + # create any class parameters here, 'some_pin' is used as an example (change/add as needed) + some_pin: int + + # Constructor + @classmethod + def new(cls, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]) -> Self: + my_class = cls(config.name) + my_class.reconfigure(config, dependencies) + return my_class + + # Validates JSON Configuration + @classmethod + def validate(cls, config: ComponentConfig): + # here we validate config, the following is just an example and should be updated as needed + some_pin = config.attributes.fields["some_pin"].number_value + if some_pin == "": + raise Exception("A some_pin must be defined") + return + + # Handles attribute reconfiguration + def reconfigure(self, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]): + # here we initialize the resource instance, the following is just an example and should be updated as needed + self.some_pin = int(config.attributes.fields["some_pin"].number_value) + LOGGER.info("Configuration success!") + return + + """ Implement the methods the Viam RDK defines for the Camera API (rdk:component:camera) """ + async def get_image( + self, mime_type: str = "", *, extra: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, **kwargs + ) -> Union[Image, RawImage]: + """Get the next image from the camera as an Image or RawImage. + Be sure to close the image when finished. + NOTE: If the mime type is ``image/vnd.viam.dep`` you can use :func:`viam.media.video.RawImage.bytes_to_depth_array` + to convert the data to a standard representation. + Args: + mime_type (str): The desired mime type of the image. This does not guarantee output type + Returns: + Image | RawImage: The frame + """ + ... + + + async def get_images(self, *, timeout: Optional[float] = None, **kwargs) -> Tuple[List[NamedImage], ResponseMetadata]: + """Get simultaneous images from different imagers, along with associated metadata. + This should not be used for getting a time series of images from the same imager. + Returns: + Tuple[List[NamedImage], ResponseMetadata]: + - List[NamedImage]: + The list of images returned from the camera system. + - ResponseMetadata: + The metadata associated with this response + """ + ... + + + async def get_point_cloud( + self, *, extra: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, **kwargs + ) -> Tuple[bytes, str]: + """ + Get the next point cloud from the camera. This will be + returned as bytes with a mimetype describing + the structure of the data. The consumer of this call + should encode the bytes into the formatted suggested + by the mimetype. + To deserialize the returned information into a numpy array, use the Open3D library. + :: + import numpy as np + import open3d as o3d + data, _ = await camera.get_point_cloud() + # write the point cloud into a temporary file + with open("/tmp/pointcloud_data.pcd", "wb") as f: + f.write(data) + pcd = o3d.io.read_point_cloud("/tmp/pointcloud_data.pcd") + points = np.asarray(pcd.points) + Returns: + bytes: The pointcloud data. + str: The mimetype of the pointcloud (e.g. PCD). + """ + ... + + + async def get_properties(self, *, timeout: Optional[float] = None, **kwargs) -> Properties: + """ + Get the camera intrinsic parameters and camera distortion parameters + Returns: + Properties: The properties of the camera + """ + ... \ No newline at end of file