From 4f1e21fa658509eae672d7f231787fbf82d61663 Mon Sep 17 00:00:00 2001 From: Wei Ouyang Date: Mon, 10 Feb 2025 13:51:19 +0100 Subject: [PATCH 1/3] Add docker file and model tester --- .dockerignore | 49 ++++++++++ Dockerfile | 37 ++++++++ bioimageio/engine/model_tester.py | 148 ++++++++++++++++++++++++++++++ requirements.txt | 9 +- scripts/create_workspace.py | 24 +++++ scripts/register_resnet.py | 72 +++++++++++++++ scripts/run_resnet.py | 24 +++++ scripts/test_hypha_ray_service.py | 35 +++++++ 8 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 bioimageio/engine/model_tester.py create mode 100644 scripts/create_workspace.py create mode 100644 scripts/register_resnet.py create mode 100644 scripts/run_resnet.py create mode 100644 scripts/test_hypha_ray_service.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eed216e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Environment files +.env +.env.* +*.env + +# Version control +.git +.gitignore +.gitattributes + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.eggs/ +*.egg-info/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Log files +*.log +logs/ + +# Local development files +docker-compose.override.yml +docker-compose.*.yml + +# Documentation +docs/ +*.md +LICENSE + +# Test files +tests/ +test/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5dc73c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Use an official slim Python 3.11 image +FROM --platform=linux/amd64 python:3.11.9-slim AS build + + +# Set the working directory +WORKDIR /app/ + +# Install necessary system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + jq \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user with sudo privileges +RUN groupadd -r bioengine && useradd -r -g bioengine --create-home bioengine \ + && usermod -aG sudo bioengine \ + && echo "bioengine ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/bioengine + +# Copy requirements first for efficient layer caching +COPY ./requirements.txt /app/requirements.txt + +# Install Python dependencies with --no-cache-dir to reduce image size +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r /app/requirements.txt + +# Copy application files last to leverage Docker's build cache +COPY . /app/ + +# Change ownership to the non-root user and ensure scripts are executable +RUN chown -R bioengine:bioengine /app/ + +# Switch to the non-root user +USER bioengine + +# Default entrypoint for running the application +ENTRYPOINT ["python", "/app/start_hypha_service.py"] \ No newline at end of file diff --git a/bioimageio/engine/model_tester.py b/bioimageio/engine/model_tester.py new file mode 100644 index 0000000..06306a5 --- /dev/null +++ b/bioimageio/engine/model_tester.py @@ -0,0 +1,148 @@ +import os + +import ray +from hypha_rpc import connect_to_server +from ray import serve + + +@serve.deployment( + ray_actor_options={"num_gpus": 1, "num_cpus": 1}, + max_ongoing_requests=1, + num_replicas=1, + max_replicas_per_node=1, + max_queued_requests=10, +) +class ModelTester: + def __init__(self): + pass + + async def _download_model(self, model_url: str, package_path: str) -> str: + import os + import zipfile + from pathlib import Path + + import aiohttp + + archive_path = package_path + ".zip" + + async with aiohttp.ClientSession() as session: + async with session.get(model_url) as response: + if response.status != 200: + raise RuntimeError(f"Failed to download model from {model_url}") + content = await response.read() + with open(archive_path, "wb") as f: + f.write(content) + + # Unzip package_path + with zipfile.ZipFile(archive_path, "r") as zip_ref: + zip_ref.extractall(package_path) + + # Cleanup the archive file + os.remove(archive_path) + + return Path(package_path) + + async def validate(self, rdf_dict): + from bioimageio.spec import ValidationContext, validate_format + + ctx = ValidationContext(perform_io_checks=False) + summary = validate_format(rdf_dict, context=ctx) + return {"success": summary.status == "passed", "details": summary.format()} + + async def __call__(self, model_id, model_url=None): + from bioimageio.core import test_model + from bioimageio.spec import save_bioimageio_package_as_folder + + package_path = f"/tmp/bioengine/{model_id}" + + if model_url is not None: + package_path = await self._download_model(model_url, package_path) + else: + package_path = save_bioimageio_package_as_folder( + model_id, output_path=package_path + ) + + assert package_path.exists() + + print("package path", package_path) + return test_model(package_path / "rdf.yaml").model_dump(mode="json") + + +async def ping(context): + return "pong" + + +async def test_model(model_id, model_url=None, context=None): + print(f"Running model '{model_id}'") + + app_handle = ray.serve.get_app_handle("bioengine") + + return await app_handle.remote(model_id, model_url) + + +async def validate(rdf_dict, context=None): + print("Validating RDF dict") + app_handle = ray.serve.get_app_handle("bioengine") + + return await app_handle.validate.remote(rdf_dict) + + +async def register_resvice(): + RAY_ADDRESS = os.getenv("RAY_ADDRESS", "ray://raycluster-kuberay-head-svc.ray-cluster.svc.cluster.local:10001") + HYPHA_SERVER_URL = os.getenv("HYPHA_SERVER_URL", "https://hypha.aicell.io") + HYPHA_WORKSPACE = os.getenv("HYPHA_WORKSPACE", "bioimage-io") + HYPHA_SERVICE_ID = os.getenv("HYPHA_SERVICE_ID", "bioimageio-model-runner") + HYPHA_TOKEN = os.getenv("HYPHA_TOKEN") + assert HYPHA_TOKEN, "Please set the HYPHA_TOKEN environment variable" + + # Connect to Ray head node + print(f"Initializing Ray with address: {RAY_ADDRESS}") + ray.init( + address=RAY_ADDRESS, + runtime_env={ + "pip": [ + "torch==2.5.1", + "torchvision==0.20.1", + "tensorflow==2.16.1", + "onnxruntime==1.20.1", + "bioimageio.core==0.7.0", + "hypha-rpc", + ], + }, + ) + + # Bind the arguments to the deployment and return an Application. + app = ModelTester.bind() + + # Deploy the application + ray.serve.run(app, name="bioengine", route_prefix=None) + + client = await connect_to_server( + {"server_url": HYPHA_SERVER_URL, "workspace": HYPHA_WORKSPACE, "token": HYPHA_TOKEN} + ) + + # Register the service + service_info = await client.register_service( + { + "id": HYPHA_SERVICE_ID, + "config": { + "visibility": "public", + "require_context": True, + }, + # Exposed functions: + "ping": ping, + "test_model": test_model, + "validate": validate, + } + ) + sid = service_info["id"] + + print(f"Service registered with ID: {sid}") + + await client.serve() + + +if __name__ == "__main__": + import asyncio + + asyncio.run(register_resvice()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d21373a..9aba907 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ -hypha-rpc -hypha>=0.15.45 +hypha-rpc==0.20.47 +hypha>=0.20.47.post12 PyYAML==6.0.1 hypha-launcher pyotritonclient -simpervisor \ No newline at end of file +simpervisor +numpy==1.26.4 +python-dotenv==1.0.1 +ray[serve]==2.33.0 \ No newline at end of file diff --git a/scripts/create_workspace.py b/scripts/create_workspace.py new file mode 100644 index 0000000..d5c75cb --- /dev/null +++ b/scripts/create_workspace.py @@ -0,0 +1,24 @@ + + +import os +import asyncio +from hypha_rpc import login, connect_to_server + +async def start_server(server_url): + print(f"Connecting to server at {server_url}") + token = await login({"server_url": server_url}) + server = await connect_to_server({'server_url': server_url, "token": token}) + + ws = await server.create_workspace({ + "name": "bioengine-apps", + "description": "Workspace for bioengine", + "persistent": True, + }) + + print(f"Workspace created: {ws['name']}") + + +if __name__ == '__main__': + server_url = 'https://hypha.aicell.io' + print(f"Starting server with URL: {server_url}") + asyncio.run(start_server(server_url)) diff --git a/scripts/register_resnet.py b/scripts/register_resnet.py new file mode 100644 index 0000000..7a89999 --- /dev/null +++ b/scripts/register_resnet.py @@ -0,0 +1,72 @@ +import asyncio +from hypha_rpc import connect_to_server +from pydantic import Field + +async def main(): + # Connect to the Hypha server + server_url = "https://hypha.aicell.io" + workspace_id = "bioengine-apps" + service_id = "ray-function-registry" + + server = await connect_to_server({"server_url": server_url}) + + # Retrieve the Ray Function Registry service + svc = await server.get_service(f"{workspace_id}/{service_id}") + + # Example script to classify an image using ResNet + example_script = """ +import torch +import torchvision.transforms as transforms +from PIL import Image +import requests +from io import BytesIO + +def execute(image_url: str): + # Load the image from the URL + response = requests.get(image_url) + img = Image.open(BytesIO(response.content)) + + # Define the transform to match what the model expects + preprocess = transforms.Compose([ + transforms.Resize(256), + transforms.CenterCrop(224), + transforms.ToTensor(), + transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), + ]) + + # Apply the preprocessing + img_tensor = preprocess(img) + img_tensor = img_tensor.unsqueeze(0) # Add a batch dimension + + # Load the ResNet model + model = torch.hub.load('pytorch/vision:v0.10.0', 'resnet18', pretrained=True) + model.eval() + + # Perform the inference + with torch.no_grad(): + output = model(img_tensor) + + # Load the labels for ImageNet + labels = requests.get("https://raw.githubusercontent.com/anishathalye/imagenet-simple-labels/master/imagenet-simple-labels.json").json() + + # Find the top predicted class + _, predicted_idx = torch.max(output, 1) + predicted_label = labels[predicted_idx.item()] + + return predicted_label +""" + + # Register the ResNet function + pip_requirements = ['torch', 'torchvision', 'requests', 'pillow'] + function_id = await svc.register_function(name="ResNet image classifier", script=example_script, pip_requirements=pip_requirements) + print(f"Registered ResNet function with id: {function_id}") + + # Example image URL + image_url = "https://cdn2.thecatapi.com/images/9vs.jpg" + + # Run the ResNet function + result = await svc.run_function(function_id=function_id, args=[image_url]) + print("Classification Result:", result) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/run_resnet.py b/scripts/run_resnet.py new file mode 100644 index 0000000..9332a7a --- /dev/null +++ b/scripts/run_resnet.py @@ -0,0 +1,24 @@ +import asyncio +from hypha_rpc import connect_to_server + +async def main(): + # Connect to the Hypha server + server_url = "https://hypha.aicell.io" + workspace_id = "bioengine-apps" + service_id = "ray-function-registry" + + server = await connect_to_server({"server_url": server_url}) + + # Retrieve the Ray Function Registry service + svc = await server.get_service(f"{workspace_id}/{service_id}") + # Example image URL + image_url = "https://i.natgeofe.com/n/d7c8f811-670c-434e-b451-5d08793dade0/NationalGeographic_2802033.jpg" + functions = await svc.list_functions() + # Fine the function named "ResNet image classifier" + resnet_function = next((f for f in functions if f["name"] == "ResNet image classifier"), None) + # Run the ResNet function + result = await svc.run_function(function_id=resnet_function["id"], args=[image_url]) + print("Classification Result:", result) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_hypha_ray_service.py b/scripts/test_hypha_ray_service.py new file mode 100644 index 0000000..a1f2607 --- /dev/null +++ b/scripts/test_hypha_ray_service.py @@ -0,0 +1,35 @@ +import asyncio +from hypha_rpc import connect_to_server + +async def main(): + # Replace with the actual server URL and workspace/service ID as needed + server_url = "https://hypha.aicell.io" + workspace_id = "bioengine-apps" + service_id = "ray-function-registry" + + # Connect to the Hypha server + server = await connect_to_server({"server_url": server_url}) + + # Retrieve the Ray Function Registry service + svc = await server.get_service(f"{workspace_id}/{service_id}") + + # List registered functions + registered_functions = await svc.list_functions() + print("Registered Functions:", registered_functions) + + # Run a registered function if available + if registered_functions: + function = registered_functions[0] # Assuming there's at least one registered function + + # Example arguments for the function + args = ["https://ichef.bbci.co.uk/images/ic/720x405/p0517py6.jpg"] + kwargs = {} + + # Invoke the function + result = await svc.run_function(function_id=function["id"], args=args, kwargs=kwargs) + print("Function Result:", result) + else: + print("No functions registered.") + +if __name__ == "__main__": + asyncio.run(main()) From 3351cf7eb1ebd8aee4ebfb92ef4414b2b5bee9ea Mon Sep 17 00:00:00 2001 From: Wei Ouyang Date: Mon, 10 Feb 2025 14:00:07 +0100 Subject: [PATCH 2/3] Update docker file --- Dockerfile | 37 -------------------------- environments/bioengine/Dockerfile | 43 ++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 43 deletions(-) delete mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 5dc73c7..0000000 --- a/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# Use an official slim Python 3.11 image -FROM --platform=linux/amd64 python:3.11.9-slim AS build - - -# Set the working directory -WORKDIR /app/ - -# Install necessary system dependencies -RUN apt-get update && apt-get install -y \ - curl \ - jq \ - sudo \ - && rm -rf /var/lib/apt/lists/* - -# Create a non-root user with sudo privileges -RUN groupadd -r bioengine && useradd -r -g bioengine --create-home bioengine \ - && usermod -aG sudo bioengine \ - && echo "bioengine ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/bioengine - -# Copy requirements first for efficient layer caching -COPY ./requirements.txt /app/requirements.txt - -# Install Python dependencies with --no-cache-dir to reduce image size -RUN pip install --upgrade pip \ - && pip install --no-cache-dir -r /app/requirements.txt - -# Copy application files last to leverage Docker's build cache -COPY . /app/ - -# Change ownership to the non-root user and ensure scripts are executable -RUN chown -R bioengine:bioengine /app/ - -# Switch to the non-root user -USER bioengine - -# Default entrypoint for running the application -ENTRYPOINT ["python", "/app/start_hypha_service.py"] \ No newline at end of file diff --git a/environments/bioengine/Dockerfile b/environments/bioengine/Dockerfile index d6ea108..5dc73c7 100644 --- a/environments/bioengine/Dockerfile +++ b/environments/bioengine/Dockerfile @@ -1,6 +1,37 @@ -FROM ghcr.io/amun-ai/hypha:0.20.35.post2 -COPY . /app -USER root -RUN pip install -r /app/requirements.txt -RUN pip install /app -USER hypha \ No newline at end of file +# Use an official slim Python 3.11 image +FROM --platform=linux/amd64 python:3.11.9-slim AS build + + +# Set the working directory +WORKDIR /app/ + +# Install necessary system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + jq \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user with sudo privileges +RUN groupadd -r bioengine && useradd -r -g bioengine --create-home bioengine \ + && usermod -aG sudo bioengine \ + && echo "bioengine ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/bioengine + +# Copy requirements first for efficient layer caching +COPY ./requirements.txt /app/requirements.txt + +# Install Python dependencies with --no-cache-dir to reduce image size +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r /app/requirements.txt + +# Copy application files last to leverage Docker's build cache +COPY . /app/ + +# Change ownership to the non-root user and ensure scripts are executable +RUN chown -R bioengine:bioengine /app/ + +# Switch to the non-root user +USER bioengine + +# Default entrypoint for running the application +ENTRYPOINT ["python", "/app/start_hypha_service.py"] \ No newline at end of file From cb9d238d35cce0314e05b6bb42689b1cc9675316 Mon Sep 17 00:00:00 2001 From: Wei Ouyang Date: Mon, 10 Feb 2025 14:22:59 +0100 Subject: [PATCH 3/3] Update model_tester.py --- bioimageio/engine/model_tester.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bioimageio/engine/model_tester.py b/bioimageio/engine/model_tester.py index 06306a5..743d77a 100644 --- a/bioimageio/engine/model_tester.py +++ b/bioimageio/engine/model_tester.py @@ -50,6 +50,7 @@ async def validate(self, rdf_dict): return {"success": summary.status == "passed", "details": summary.format()} async def __call__(self, model_id, model_url=None): + import shutil from bioimageio.core import test_model from bioimageio.spec import save_bioimageio_package_as_folder @@ -64,8 +65,12 @@ async def __call__(self, model_id, model_url=None): assert package_path.exists() - print("package path", package_path) - return test_model(package_path / "rdf.yaml").model_dump(mode="json") + result = test_model(package_path / "rdf.yaml").model_dump(mode="json") + + # Cleanup after test run + shutil.rmtree(str(package_path)) + + return result async def ping(context): @@ -145,4 +150,4 @@ async def register_resvice(): if __name__ == "__main__": import asyncio - asyncio.run(register_resvice()) \ No newline at end of file + asyncio.run(register_resvice())