From 0a5350973d882439cd5fa19fe2394804aafd3090 Mon Sep 17 00:00:00 2001 From: Forrest Date: Mon, 6 Feb 2023 16:47:33 -0500 Subject: [PATCH 01/22] feat(server): initial python server This adds support for bidirectional python server integration with VolView. --- .env.example | 3 + package-lock.json | 183 ++++++++++- package.json | 5 +- server/.flake8 | 3 + server/README.md | 144 +++++++++ server/custom/user_api.py | 27 ++ server/helper.py | 147 --------- server/host.js | 134 -------- server/poetry.lock | 468 +++++++++++++++++++++++++++ server/pyproject.toml | 23 ++ server/serialize.js | 111 ------- server/serialize.py | 203 ------------ server/server.py | 84 ----- server/test.py | 17 - server/volview_server/__init__.py | 6 + server/volview_server/__main__.py | 53 +++ server/volview_server/api.py | 6 + server/volview_server/converters.py | 113 +++++++ server/volview_server/itk_helpers.py | 70 ++++ server/volview_server/rpc_server.py | 215 ++++++++++++ src/components/ModulePanel.vue | 24 +- src/components/ServerModule.vue | 244 ++++++++++++++ src/core/remote/client.ts | 297 +++++++++++++++++ src/store/datasets-images.ts | 3 + src/types/index.ts | 11 + 25 files changed, 1878 insertions(+), 716 deletions(-) create mode 100644 server/.flake8 create mode 100644 server/README.md create mode 100644 server/custom/user_api.py delete mode 100644 server/helper.py delete mode 100644 server/host.js create mode 100644 server/poetry.lock create mode 100644 server/pyproject.toml delete mode 100644 server/serialize.js delete mode 100644 server/serialize.py delete mode 100644 server/server.py delete mode 100644 server/test.py create mode 100644 server/volview_server/__init__.py create mode 100644 server/volview_server/__main__.py create mode 100644 server/volview_server/api.py create mode 100644 server/volview_server/converters.py create mode 100644 server/volview_server/itk_helpers.py create mode 100644 server/volview_server/rpc_server.py create mode 100644 src/components/ServerModule.vue create mode 100644 src/core/remote/client.ts diff --git a/.env.example b/.env.example index ea540461f..3223c6c48 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ VITE_SENTRY_DSN= # If this env var exists and is true and there is a `save` URL parameter, # clicking the save button POSTS the session.volview.zip file to the specifed URL. VITE_ENABLE_REMOTE_SAVE=true + +# VolView server remote URL +VITE_REMOTE_SERVER_URL=http://localhost:4014 diff --git a/package-lock.json b/package-lock.json index 6ec1d43ba..35cb64f6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,12 +25,13 @@ "itk-wasm": "1.0.0-b.130", "jszip": "3.10.0", "mitt": "^3.0.0", + "nanoid": "^4.0.1", "pinia": "^2.0.34", + "socket.io-client": "^4.6.2", "vue": "^3.3.4", "vue-toastification": "^2.0.0-rc.5", "vuetify": "^3.1.14", "whatwg-url": "^12.0.1", - "wslink": "^1.6.4", "zod": "^3.18.0" }, "devDependencies": { @@ -4122,6 +4123,11 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", @@ -8512,6 +8518,46 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz", + "integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", @@ -14281,6 +14327,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/mocha/node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -14440,15 +14498,20 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true, + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^14 || ^16 || >=18" } }, "node_modules/natural-compare": { @@ -17590,6 +17653,32 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.2.tgz", + "integrity": "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", @@ -20563,6 +20652,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/wslink/-/wslink-1.10.0.tgz", "integrity": "sha512-xwSGJc+j5eUwQ8CjdLfp+9WdyqLJ53qa2XCJKTkaAXqSetRfqERL4bFMG1+llfqq9nd+Hc54MJCYFRq/e5GGmQ==", + "peer": true, "dependencies": { "json5": "2.2.0" } @@ -20571,6 +20661,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", + "peer": true, "dependencies": { "minimist": "^1.2.5" }, @@ -20627,6 +20718,14 @@ "optional": true, "peer": true }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -24136,6 +24235,11 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "@szmarczak/http-timer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", @@ -27519,6 +27623,31 @@ "once": "^1.4.0" } }, + "engine.io-client": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz", + "integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + }, + "dependencies": { + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "requires": {} + } + } + }, + "engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==" + }, "enhanced-resolve": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", @@ -31863,6 +31992,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true + }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -32000,10 +32135,9 @@ "dev": true }, "nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==" }, "natural-compare": { "version": "1.4.0", @@ -34364,6 +34498,26 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true }, + "socket.io-client": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.2.tgz", + "integrity": "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, "socks": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", @@ -36524,6 +36678,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/wslink/-/wslink-1.10.0.tgz", "integrity": "sha512-xwSGJc+j5eUwQ8CjdLfp+9WdyqLJ53qa2XCJKTkaAXqSetRfqERL4bFMG1+llfqq9nd+Hc54MJCYFRq/e5GGmQ==", + "peer": true, "requires": { "json5": "2.2.0" }, @@ -36532,6 +36687,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", + "peer": true, "requires": { "minimist": "^1.2.5" } @@ -36577,6 +36733,11 @@ "optional": true, "peer": true }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index a99bda0b1..3c52f3ea6 100644 --- a/package.json +++ b/package.json @@ -40,12 +40,13 @@ "itk-wasm": "1.0.0-b.130", "jszip": "3.10.0", "mitt": "^3.0.0", + "nanoid": "^4.0.1", "pinia": "^2.0.34", + "socket.io-client": "^4.6.2", "vue": "^3.3.4", "vue-toastification": "^2.0.0-rc.5", "vuetify": "^3.1.14", "whatwg-url": "^12.0.1", - "wslink": "^1.6.4", "zod": "^3.18.0" }, "devDependencies": { @@ -122,4 +123,4 @@ "eslint" ] } -} \ No newline at end of file +} diff --git a/server/.flake8 b/server/.flake8 new file mode 100644 index 000000000..e0ea542fd --- /dev/null +++ b/server/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203 \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 000000000..0e8c06e38 --- /dev/null +++ b/server/README.md @@ -0,0 +1,144 @@ +# VolView Server + +The VolView server is a Python WebSocket service that exposes RPC endpoints. +Customize VolView to: + +1. Filter and segment images +1. Load remote data +1. Run AI models and return results + +## Quickstart + +In the VolView root directory, one level up, create a `.env` file with +`VUE_APP_REMOTE_SERVER_URL=http://localhost:4014` + +VolView uses poetry for managing the virtualenv and dependencies. To install, +run `pip3 install poetry`. To launch the server: + +``` +$ cd server/ +$ poetry install +$ poetry run python -m volview_server -P 4014 ./custom/user_api.py +``` + +Launch VolView (e.g. using `npm run serve`) and check out the "Remote Functions" +tab! The Python server must be running before VolView loads. + +## Customizing the RPC API + +The following is a definition of a really simple RPC API that adds two numbers. + +```python +from volview_server import VolViewApi, expose + +class Api(VolViewApi): + @expose + def add(self, a: int, b: int): + return a + b +``` + +The `expose` decorator exposes the `add` function with the public name `add`. To +customize the name, change the decorator to `@expose("myAddFunction")`. + +An example set of RPC endpoints are defined in `custom/user_api.py`. + +### Custom Object Encoding + +If you have encoded objects that have a native Python representation, you can +add custom serializers and deserializers to properly handle those objects. + +The serializer/deserializer functions should either return a transformed result, +or pass through the input if no transformation was applied. + + +```python +from datetime import datetime +from volview_server import VolViewApi, expose + +DATETIME_FORMAT = "%Y%m%dT%H:%M:%S.%f" + +def decode_datetime(obj): + if "__datetime__" in obj: + return datetime.strptime(obj["__datetime__"], DATETIME_FORMAT) + return obj + +def encode_datetime(dt): + if isinstance(dt, datetime): + return {"__datetime__": dt.strftime(DATETIME_FORMAT)} + return dt + +class Api(VolViewApi): + def __init__(self, *args, **kwargs): + super().__init__( + *args, + serializers=[encode_datetime], + deserializers=[decode_datetime], + **kwargs + ) + + @expose + def echo_datetime(self, dt: datetime): + print(type(dt), dt) + return dt +``` + +### Async Support + +Async methods are supported via asyncio. + +```python +import asyncio +from volview_server import VolViewApi, expose + +class Api(VolViewApi): + @expose + async def sleep(self): + await asyncio.sleep(5) +``` + +### Progress via Streaming Async Generators + +If the exposed method is an async generator, the function is automatically +considered to be a streaming method. + +```python +import asyncio +from volview_server import VolViewApi, expose + +class Api(VolViewAPI): + @expose + async def progress(self): + for i in range(100): + yield { "progress": i, "done": False } + await asyncio.sleep(0.1) + yield { "progress": 100, "done": True } +``` + +On the client, instead of using `client.call(...)`, you must use +`client.stream(...)`. + +```javascript +await client.stream('progress', [/* optional args */], (data) => { + const { progress, done } = data; + ... +}) +``` + +## Accessing Client Stores + +It is possible for RPC methods to access the client application stores using `self.get_client_store(store_name)`. This feature allows the server to control the calling client and make modifications, such as adding new images, updating annotations, switching the viewed image, and more! + +An example of this is the `medianFilter` RPC example in `custom/user_api.py`. + +```python +import asyncio +from volview_server import VolViewApi, expose + +class Api(VolViewAPI): + @expose + async def access_client(self): + store = self.get_client_store('images') + image_id_list = await store.idList + + new_image = self.create_new_itk_image() + await store.addVTKImageData('My image', new_image) diff --git a/server/custom/user_api.py b/server/custom/user_api.py new file mode 100644 index 000000000..70ce9de82 --- /dev/null +++ b/server/custom/user_api.py @@ -0,0 +1,27 @@ +import asyncio + +from volview_server import VolViewApi, expose +import aiohttp + + +class Api(VolViewApi): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @expose + def add(self, a, b): + return a + b + + @expose # exposes as "number_trivia" + @expose("get_number_trivia") # exposes as "get_number_trivia" + async def number_trivia(self): + async with aiohttp.ClientSession() as session: + url = "http://numbersapi.com/random/" + async with session.get(url) as resp: + return await resp.text() + + @expose("progress") + async def number_stream(self): + for i in range(1, 101): + yield {"progress": i} + await asyncio.sleep(0.1) diff --git a/server/helper.py b/server/helper.py deleted file mode 100644 index 1b6673a31..000000000 --- a/server/helper.py +++ /dev/null @@ -1,147 +0,0 @@ -import traceback -import wslink -from serialize import RpcDecoder, RpcEncoder, DEFAULT_DECODERS, DEFAULT_ENCODERS -from wslink.websocket import LinkProtocol - - -RPC_DEFERRED_TYPE = 'rpc.deferred' -RPC_RESULT_TYPE = 'rpc.result' -RPC_ERROR_TYPE = 'rpc.error' -DEFERRED_RESPONSE_TYPE = 'deferred.response' - - -class FutureResult(object): - def __init__(self, id): - self._id = id - self._done = False - self._result = None - self._exception = None - self._callbacks = [] - - def id(self): - return self._id - - def done(self): - return self._done - - def result(self): - if not self._done: - raise Exception('Future is not done') - return self._result - - def exception(self): - if not self._done: - raise Exception('Future is not done') - return self._exception - - def has_result(self): - return self._done and bool(self._result) - - def has_exception(self): - return self._done and bool(self._exception) - - def add_done_callback(self, cb): - self._callbacks.append(cb) - - def remove_done_callback(self, cb): - self._callbacks.remove(cb) - - def set_result(self, result): - if not self._done: - self._done = True - self._result = result - self._trigger_callbacks() - - def set_exception(self, exception): - if not self._done: - self._done = True - self._exception = exception - self._trigger_callbacks() - - def _trigger_callbacks(self): - for cb in self._callbacks: - try: - cb(self) - except: - pass - - -def make_deferred_response(future): - return { - 'type': RPC_DEFERRED_TYPE, - 'id': future.id(), - } - - -def make_result_response(result): - return { - 'type': RPC_RESULT_TYPE, - 'result': result, - } - - -def make_error_response(exc): - return { - 'type': RPC_ERROR_TYPE, - 'error': str(exc), - } - - -def deserialize(args, kwargs): - decoder = RpcDecoder(hooks=DEFAULT_DECODERS) - new_args = [decoder.decode(arg) for arg in args] - return new_args, kwargs - - -def rpc(name): - def wrapper(fn): - def handler(self, *args, **kwargs): - - try: - args, kwargs = deserialize(args, kwargs) - result = fn(self, *args, **kwargs) - except Exception as e: - traceback.print_exc() - return make_error_response(e) - else: - if type(result) is FutureResult: - self._futures.append(result) - result.set_done_callback(self._handle_future_result) - return make_deferred_response(result.id()) - - return make_result_response(self.serialize(result)) - - return wslink.register(name)(handler) - return wrapper - - -class RpcApi(LinkProtocol): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._futures = [] - - def _handle_future_result(self, future): - self._futures.remove(future) - self.publish('deferred.responses', - self._make_deferred_result_response(future)) - - def _make_deferred_result_response(self, future): - if future.has_exception(): - return { - 'type': DEFERRED_RESPONSE_TYPE, - 'id': future.id(), - 'rpcResponse': make_error_response(future.exception()) - } - return { - 'type': DEFERRED_RESPONSE_TYPE, - 'id': future.id(), - 'rpcResponse': make_result_response(self.serialize(future.result())) - } - - def serialize(self, obj): - try: - encoder = RpcEncoder(encoders=DEFAULT_ENCODERS, - extra_args=(self.addAttachment,)) - return encoder.encode(obj) - except: - traceback.print_exc() diff --git a/server/host.js b/server/host.js deleted file mode 100644 index d22f7f254..000000000 --- a/server/host.js +++ /dev/null @@ -1,134 +0,0 @@ -/* eslint-disable-next-line max-classes-per-file */ -import WebsocketConnection from 'wslink/src/WebsocketConnection'; -import { defer } from '../src/utils/common'; - -import { deserialize, serialize } from './serialize'; - -/** - * Matches a response against valid type names - */ -function isValidResponse(response, types) { - const rtype = response?.type; - if (!types.includes(rtype)) { - return false; - } - switch (rtype) { - case 'rpc.result': - return 'result' in response; - case 'rpc.error': - return 'error' in response; - case 'rpc.deferred': - return 'id' in response; - case 'deferred.response': - return 'id' in response && 'rpcResponse' in response; - default: - return false; - } -} - -export class RpcError extends Error {} - -export default class HostConnection { - constructor(wsUrl) { - this.ws = null; - this.wsUrl = wsUrl; - this.connected = false; - this.deferredResponses = new Map(); - } - - async connect() { - return new Promise((resolve, reject) => { - if (!this.connected) { - this.ws = WebsocketConnection.newInstance({ urls: this.wsUrl }); - - this.ws.onConnectionReady(() => { - this.connected = true; - this.session = this.ws.getSession(); - - this.session.subscribe( - 'deferred.responses', - this.handleDeferredResponse - ); - - resolve(); - }); - - this.ws.onConnectionClose(() => { - this.connected = false; - }); - - this.ws.onConnectionError(() => { - reject(new Error('Failed to connect to ws endpoint')); - }); - - this.ws.connect(); - } - }); - } - - async disconnect() { - if (this.connected) { - this.ws.destroy(); - } - } - - async call(endpoint, ...args) { - if (!this.connected) { - throw new Error('Not connected'); - } - - const attach = (obj) => { - if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) { - return this.session.addAttachment(obj.buffer); - } - return obj; - }; - - const preparedArgs = args.map((arg) => serialize(arg, attach)); - - const response = await this.session.call(endpoint, preparedArgs); - return this.handleRpcResponse(response); - } - - async handleRpcResponse(response) { - if ( - !isValidResponse(response, ['rpc.result', 'rpc.error', 'rpc.deferred']) - ) { - throw new Error('Invalid response from rpc'); - } - - if (response.type === 'rpc.deferred') { - const deferred = defer(); - this.deferredResponses.set(response.id, deferred); - return deferred.promise; - } - - if (response.type === 'rpc.error') { - throw new RpcError(response.error); - } - - const result = await deserialize(response.result); - return result; - } - - async handleDeferredResponse(response) { - if (!isValidResponse(response, ['deferred.result'])) { - throw new Error('Invalid deferred response'); - } - - const { id } = response; - if (!this.deferredResponses.has(id)) { - throw new Error('Received a deferred response for a nonexistent call'); - } - - const deferred = this.deferredResponses.get(id); - this.deferredResponses.delete(id); - - try { - const result = await this.handleRpcResponse(response.rpcResponse); - deferred.resolve(result); - } catch (e) { - deferred.reject(e); - } - } -} diff --git a/server/poetry.lock b/server/poetry.lock new file mode 100644 index 000000000..970f79c62 --- /dev/null +++ b/server/poetry.lock @@ -0,0 +1,468 @@ +[[package]] +name = "aiohttp" +version = "3.8.3" +description = "Async http client/server framework (asyncio)" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotli", "cchardet"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "attrs" +version = "22.2.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +cov = ["attrs", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] +dev = ["attrs"] +docs = ["furo", "sphinx", "myst-parser", "zope.interface", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["attrs", "zope.interface"] +tests-no-zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] +tests_no_zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] + +[[package]] +name = "bidict" +version = "0.22.1" +description = "The bidirectional mapping library for Python." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "sphinx-copybutton", "furo"] +lint = ["pre-commit"] +test = ["hypothesis", "pytest", "pytest-benchmark", "pytest-cov", "pytest-xdist", "sortedcollections", "sortedcontainers", "sphinx"] + +[[package]] +name = "black" +version = "23.1.0" +description = "The uncompromising code formatter." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "exceptiongroup" +version = "1.1.0" +description = "Backport of PEP 654 (exception groups)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flake8" +version = "6.0.0" +description = "the modular source code checker: pep8 pyflakes and co" +category = "main" +optional = false +python-versions = ">=3.8.1" + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.10.0,<2.11.0" +pyflakes = ">=3.0.0,<3.1.0" + +[[package]] +name = "frozenlist" +version = "1.3.3" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "itk" +version = "5.3.0" +description = "ITK is an open-source toolkit for multidimensional image analysis" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +itk-core = "5.3.0" +itk-filtering = "5.3.0" +itk-io = "5.3.0" +itk-numerics = "5.3.0" +itk-registration = "5.3.0" +itk-segmentation = "5.3.0" +numpy = "*" + +[[package]] +name = "itk-core" +version = "5.3.0" +description = "ITK is an open-source toolkit for multidimensional image analysis" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +numpy = "*" + +[[package]] +name = "itk-filtering" +version = "5.3.0" +description = "ITK is an open-source toolkit for multidimensional image analysis" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +itk-numerics = "5.3.0" + +[[package]] +name = "itk-io" +version = "5.3.0" +description = "ITK is an open-source toolkit for multidimensional image analysis" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +itk-core = "5.3.0" + +[[package]] +name = "itk-numerics" +version = "5.3.0" +description = "ITK is an open-source toolkit for multidimensional image analysis" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +itk-core = "5.3.0" + +[[package]] +name = "itk-registration" +version = "5.3.0" +description = "ITK is an open-source toolkit for multidimensional image analysis" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +itk-filtering = "5.3.0" + +[[package]] +name = "itk-segmentation" +version = "5.3.0" +description = "ITK is an open-source toolkit for multidimensional image analysis" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +itk-filtering = "5.3.0" + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "msgpack" +version = "1.0.4" +description = "MessagePack serializer" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "multidict" +version = "6.0.4" +description = "multidict implementation" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "numpy" +version = "1.24.1" +description = "Fundamental package for array computing in Python" +category = "main" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "packaging" +version = "23.0" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pathspec" +version = "0.11.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "platformdirs" +version = "2.6.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.19.5)", "sphinx (>=5.3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pycodestyle" +version = "2.10.0" +description = "Python style guide checker" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pyflakes" +version = "3.0.1" +description = "passive checker of Python programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pytest" +version = "7.2.1" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "python-engineio" +version = "4.4.1" +description = "Engine.IO server and client for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +name = "python-socketio" +version = "5.8.0" +description = "Socket.IO server and client for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +bidict = ">=0.21.0" +python-engineio = ">=4.3.0" + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "yarl" +version = "1.8.2" +description = "Yet another URL library" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[metadata] +lock-version = "1.1" +python-versions = "^3.9" +content-hash = "b31032eb253e74e3fc0ffc2bef0d14acbb64e8412db60331646b53cfce194c00" + +[metadata.files] +aiohttp = [] +aiosignal = [] +async-timeout = [] +attrs = [] +bidict = [] +black = [] +charset-normalizer = [] +click = [] +colorama = [] +exceptiongroup = [] +flake8 = [] +frozenlist = [] +idna = [] +iniconfig = [] +itk = [] +itk-core = [] +itk-filtering = [] +itk-io = [] +itk-numerics = [] +itk-registration = [] +itk-segmentation = [] +mccabe = [] +msgpack = [] +multidict = [] +mypy-extensions = [] +numpy = [] +packaging = [] +pathspec = [] +platformdirs = [] +pluggy = [] +pycodestyle = [] +pyflakes = [] +pytest = [] +python-engineio = [] +python-socketio = [] +tomli = [] +typing-extensions = [] +yarl = [] diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 000000000..33857cf45 --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,23 @@ +[tool.poetry] +name = "volview_server" +version = "0.1.0" +description = "The VolView Python Server" +authors = ["Forrest "] +license = "Apache 2.0" + +[tool.poetry.dependencies] +python = "^3.9" +itk = "^5.3.0" +black = "^23.1.0" +flake8 = "^6.0.0" +pytest = "^7.2.1" +numpy = "^1.24.1" +msgpack = "^1.0.4" +aiohttp = "^3.8.3" +python-socketio = "^5.8.0" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/server/serialize.js b/server/serialize.js deleted file mode 100644 index 54f47c162..000000000 --- a/server/serialize.js +++ /dev/null @@ -1,111 +0,0 @@ -import vtk from '@kitware/vtk.js/vtk'; - -export const BREAK = Symbol('Break'); - -export function vtkObjectReplacer(vo) { - if (vo?.isA?.('vtkObject')) { - return vo.getState(); - } - return vo; -} - -export function vtkDataArrayJSONReplacer(da, attach) { - if (da?.classHierarchy?.includes?.('vtkDataArray')) { - const values = new globalThis[da.dataType](da.values); - return { - ...da, - values: attach(values), - }; - } - return da; -} - -/** - * Serializes typed arrays into regular arrays - */ -export function typedArrayReplacer(ta) { - if (ArrayBuffer.isView(ta) && !(ta instanceof DataView)) { - return Array.from(ta); - } - return ta; -} - -export async function vtkDataArrayValueReviver(da) { - if (da?.vtkClass === 'vtkDataArray' && da?.values instanceof Blob) { - const ab = await da.values.arrayBuffer(); - return { - ...da, - values: new globalThis[da.dataType](ab), - }; - } - return da; -} - -export function vtkObjectReviver(obj) { - if ('vtkClass' in obj) { - return vtk(obj); - } - return obj; -} - -const REPLACERS = [ - vtkObjectReplacer, - vtkDataArrayJSONReplacer, - typedArrayReplacer, -]; - -const REVIVERS = [ - vtkDataArrayValueReviver, - // should be after the vtkDataArrayValueReviver - vtkObjectReviver, -]; - -export function serialize(obj, attach) { - return JSON.parse( - JSON.stringify(obj, (key, value) => { - let transformed = value; - for (let i = 0; i < REPLACERS.length; i += 1) { - const result = REPLACERS[i](transformed, attach); - if (result === BREAK) { - break; - } - transformed = result; - } - return transformed; - }) - ); -} - -export async function deserialize(obj) { - async function revive(o) { - let transformed = o; - for (let i = 0; i < REVIVERS.length; i += 1) { - // eslint-disable-next-line no-await-in-loop - const result = await REVIVERS[i](transformed); - if (result === BREAK) { - break; - } - transformed = result; - } - return transformed; - } - - async function recurseHelper(o) { - if (Array.isArray(o)) { - const newArray = await Promise.all(o.map((item) => recurseHelper(item))); - return revive(newArray); - } - if (o instanceof Object && o.constructor === Object) { - const newO = {}; - await Promise.all( - Object.keys(o).map(async (k) => { - newO[k] = await recurseHelper(o[k]); - }) - ); - return revive(newO); - } - return o; - } - - return recurseHelper(obj); -} diff --git a/server/serialize.py b/server/serialize.py deleted file mode 100644 index 93e2b1ab8..000000000 --- a/server/serialize.py +++ /dev/null @@ -1,203 +0,0 @@ -import json -import struct -import itk -import numpy as np - -JS_TO_NPY_TYPEMAP = { - 'Int8Array': { - 'struct': (1, 'b'), - 'dtype': 'int8', - }, - 'Int16Array': { - 'struct': (2, 'h'), - 'dtype': 'int16', - }, - 'Int32Array': { - 'struct': (4, 'i'), - 'dtype': 'int32', - }, - 'Uint8Array': { - 'struct': (1, 'B'), - 'dtype': 'uint8', - }, - 'Uint16Array': { - 'struct': (2, 'H'), - 'dtype': 'uint16', - }, - 'Uint32Array': { - 'struct': (4, 'I'), - 'dtype': 'uint32', - }, - 'Float32Array': { - 'struct': (4, 'f'), - 'dtype': 'float32', - }, - 'Float64Array': { - 'struct': (8, 'd'), - 'dtype': 'float64', - }, -} - -ITK_COMP_TO_JS_TYPEMAP = { - 'SC': 'Int8Array', - 'UC': 'Uint8Array', - 'SS': 'Int16Array', - 'US': 'Uint16Array', - 'SI': 'Int32Array', - 'UI': 'Uint32Array', - 'F': 'Float32Array', - 'D': 'Float64Array', - 'B': 'Uint8Array' -} - - -def bytebuffer_to_numpy(blob, js_type): - typeinfo = JS_TO_NPY_TYPEMAP[js_type] - size, fmt = typeinfo['struct'] - dtype = np.dtype(typeinfo['dtype']) - - if len(blob) % size != 0: - raise ValueError('given byte buffer is not aligned to the type') - - full_fmt = '<{0}{1}'.format(len(blob) // size, fmt) - return np.array(struct.unpack(full_fmt, blob), dtype=dtype, copy=False) - - -def itk_image_pixel_type_to_js(itk_image): - component_str = repr(itk_image).split( - 'itkImagePython.')[1].split(';')[0][8:] - # TODO handle mangling as per https://github.com/InsightSoftwareConsortium/itk-jupyter-widgets/blob/master/itkwidgets/trait_types.py#L49 - return ITK_COMP_TO_JS_TYPEMAP[component_str[:-1]] - - -class RpcEncoder(object): - def __init__(self, encoders=[], extra_args=[], extra_kwargs={}): - self._encoders = list(encoders) - self._extra_args = extra_args - self._extra_kwargs = extra_kwargs - - def add_encoder(self, encoder): - self._encoders.append(encoder) - - def remove_encoder(self, encoder): - self._encoders.remove(encoder) - - def run_encoders(self, obj): - output = obj - for encoder in self._encoders: - output = encoder(output, *self._extra_args, **self._extra_kwargs) - return output - - def encode(self, obj): - # run on every possible value - if isinstance(obj, list): - return self.run_encoders([self.encode(item) for item in obj]) - if isinstance(obj, dict): - return self.run_encoders({k: self.encode(v) for k, v in obj.items()}) - else: - return self.run_encoders(obj) - - -class RpcDecoder(object): - def __init__(self, hooks=[], extra_args=[], extra_kwargs={}): - self._hooks = list(hooks) - self._extra_args = extra_args - self._extra_kwargs = extra_kwargs - - def add_hook(self, hook): - self._hooks.append(hook) - - def remove_hook(self, hook): - self._hooks.remove(hook) - - def run_hooks(self, val): - output = val - for hook in self._hooks: - output = hook(output, *self._extra_args, **self._extra_kwargs) - return output - - def decode(self, obj): - # only run hooks on dictionaries - if isinstance(obj, list): - return [self.decode(item) for item in obj] - if isinstance(obj, dict): - return self.run_hooks({k: self.decode(v) for k, v in obj.items()}) - else: - return obj - - -def itk_image_encoder(obj, attach): - if type(obj).__name__.startswith('itkImage'): - img = obj - size = list(img.GetLargestPossibleRegion().GetSize()) - values = itk.GetArrayFromImage(img).flatten(order='C') - return { - 'vtkClass': 'vtkImageData', - 'dataDescription': 8, - 'direction': list( - itk.GetArrayFromVnlMatrix( - img.GetDirection().GetVnlMatrix().as_matrix() - ).flatten() - ), - 'extent': [ - 0, size[0] - 1, - 0, size[1] - 1, - 0, size[2] - 1, - ], - 'spacing': list(img.GetSpacing()), - 'origin': list(img.GetOrigin()), - 'pointData': { - 'vtkClass': 'vtkDataSetAttributes', - 'activeScalars': 0, # the index of the only array - 'arrays': [ - { - 'data': { - 'vtkClass': 'vtkDataArray', - 'size': len(values), - 'values': attach(values.tobytes()), - 'dataType': itk_image_pixel_type_to_js(img), - 'numberOfComponents': img.GetNumberOfComponentsPerPixel(), - 'name': 'Scalars', - } - } - ] - } - } - return obj - - -def itk_image_decode_hook(obj): - if isinstance(obj, dict) and obj.get('vtkClass', None) == 'vtkImageData': - data_array = obj['pointData']['arrays'][0]['data'] - pixel_data = bytebuffer_to_numpy( - data_array['values'], data_array['dataType']) - - extent = obj['extent'] - # numpy indexes in ZYX order, where X varies the fastest - dims = [ - extent[5] - extent[4] + 1, - extent[3] - extent[2] + 1, - extent[1] - extent[0] + 1, - ] - direction = np.zeros((3, 3)) - for x in range(3): - for y in range(3): - direction[x][y] = obj['direction'][x*3+y] - - itk_image = itk.GetImageFromArray(np.reshape(pixel_data, dims)) - # https://discourse.itk.org/t/set-image-direction-from-numpy-array/844/10 - vnlmat = itk.GetVnlMatrixFromArray(direction) - itk_image.GetDirection().GetVnlMatrix().copy_in(vnlmat.data_block()) - itk_image.SetOrigin(obj['origin']) - itk_image.SetSpacing(obj['spacing']) - return itk_image - return obj - - -DEFAULT_DECODERS = [ - itk_image_decode_hook, -] - -DEFAULT_ENCODERS = [ - itk_image_encoder, -] diff --git a/server/server.py b/server/server.py deleted file mode 100644 index 9fb7fe336..000000000 --- a/server/server.py +++ /dev/null @@ -1,84 +0,0 @@ -import sys -import os -import webbrowser -import socket -import argparse -import importlib - -from wslink.websocket import ServerProtocol -from wslink import server -from twisted.internet import reactor - - -def get_port(): - # Don't care about race condition here for getting a free port - # if someone binds the port between get_port() and actually binding, - # then the server won't start - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind(('localhost', 0)) - _, port = sock.getsockname() - sock.close() - return port - - -def create_protocol(ApiClass): - class ApiProtocol(ServerProtocol): - authKey = 'wslink-secret' - - @staticmethod - def configure(options): - ApiProtocol.authKey = options.authKey - - def initialize(self): - self.registerLinkProtocol(ApiClass()) - self.updateSecret(ApiProtocol.authKey) - - return ApiProtocol - - -if __name__ == '__main__': - # https://stackoverflow.com/questions/7674790/bundling-data-files-with-pyinstaller-onefile - try: - basepath = sys._MEIPASS - except: - basepath = os.path.dirname(os.path.dirname(sys.argv[0])) - - parser = argparse.ArgumentParser() - parser.add_argument('-H', '--host', default='localhost', - help='Hostname for server to listen on') - parser.add_argument('-P', '--port', default=get_port(), - help='Port for server to listen on') - parser.add_argument('-b', '--no-browser', action='store_true', - help='Do not auto-open the browser') - parser.add_argument('api_script', - help='Python file that exposes ServerApi') - args = parser.parse_args() - - static_dir = os.path.join(basepath, 'www') - host = args.host - port = args.port - server_args = [ - '--content', static_dir, - '--host', host, - '--port', str(port) - ] - - wsurl = 'ws://{host}:{port}/ws'.format(host=host, port=port) - full_url = 'http://{host}:{port}/?wsServer={wsurl}'.format( - host=host, port=port, wsurl=wsurl) - - def open_webapp(): - webbrowser.open(full_url) - - # if not args.no_browser: - # print('If the browser doesn\'t open, navigate to:', full_url) - # reactor.callLater(0.1, open_webapp) - - sys.path.append(os.path.dirname(os.path.realpath(__file__))) - - spec = importlib.util.spec_from_file_location('Api', args.api_script) - api_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(api_module) - - server.start(server_args, create_protocol(api_module.Api)) - server.stop_webserver() diff --git a/server/test.py b/server/test.py deleted file mode 100644 index b534d983b..000000000 --- a/server/test.py +++ /dev/null @@ -1,17 +0,0 @@ -import itk -import json -from helper import RpcApi, rpc - - -class Api(RpcApi): - - @rpc('run') - def test(self, arg1, arg2): - print(type(arg1), type(arg2), arg2) - with open('spleen_10.json', 'r') as fp: - mm = json.load(fp) - return { - 'segmentation': itk.imread('spleen_10-label.nrrd'), - # 'segmentation': itk.imread('/home/forrestli/data/Branch-label.nrrd'), - 'measurements': mm, - } diff --git a/server/volview_server/__init__.py b/server/volview_server/__init__.py new file mode 100644 index 000000000..3043b4b0c --- /dev/null +++ b/server/volview_server/__init__.py @@ -0,0 +1,6 @@ +__version__ = "0.1.0" +__author__ = "Kitware, Inc." +__all__ = ["VolViewAPI", "expose"] + +from volview_server.api import VolViewApi +from volview_server.rpc_server import expose diff --git a/server/volview_server/__main__.py b/server/volview_server/__main__.py new file mode 100644 index 000000000..646d10373 --- /dev/null +++ b/server/volview_server/__main__.py @@ -0,0 +1,53 @@ +import sys +import os +import argparse +import importlib + +from aiohttp import web + +from volview_server.rpc_server import RpcServer + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "-H", "--host", default="localhost", help="Hostname for server to listen on" + ) + parser.add_argument( + "-P", "--port", default=4014, help="Port for server to listen on" + ) + parser.add_argument("api_script", help="Python file that exposes ServerApi") + return parser.parse_args() + + +def load_api_script(api_script: str): + spec = importlib.util.spec_from_file_location("Api", api_script) + api_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(api_module) + return api_module.Api + + +def run_server(ApiClass, *, host: str, port: int, **kwargs): + rpc_server = RpcServer(ApiClass, **kwargs) + web.run_app(rpc_server.app, host=host, port=port) + + # cleanup + rpc_server.teardown() + + +def main(args): + ApiClass = load_api_script(args.api_script) + run_server( + ApiClass, + host=args.host, + port=args.port, + # RpcServer kwargs + async_mode="aiohttp", + async_handlers=True, + cors_allowed_origins="*", + ) + + +if __name__ == "__main__": + sys.path.append(os.path.dirname(os.path.realpath(__file__))) + main(parse_args()) diff --git a/server/volview_server/api.py b/server/volview_server/api.py new file mode 100644 index 000000000..136074d41 --- /dev/null +++ b/server/volview_server/api.py @@ -0,0 +1,6 @@ +from volview_server.rpc_server import RpcServer + + +class VolViewApi: + def __init__(self, server: RpcServer): + self._server = server diff --git a/server/volview_server/converters.py b/server/volview_server/converters.py new file mode 100644 index 000000000..e39d68ffe --- /dev/null +++ b/server/volview_server/converters.py @@ -0,0 +1,113 @@ +from typing import Dict + +import itk +import numpy as np + +from volview_server.itk_helpers import itk_image_pixel_type_to_js, JS_TO_NPY_TYPEMAP + + +class ConvertError(Exception): + """An error occurred while converting.""" + + +def vtk_to_itk_image(vtk_image: Dict): + """Converts a serialized vtkImageData to an ITK image.""" + if not isinstance(vtk_image, dict): + raise ConvertError("Provided vtk_image is not a dict") + if vtk_image.get("vtkClass", None) != "vtkImageData": + raise ConvertError("Provided vtk_image is not a serialized vtkImageData") + + try: + extent = vtk_image["extent"] + # numpy indexes in ZYX order, where X varies the fastest + dims = [ + extent[5] - extent[4] + 1, + extent[3] - extent[2] + 1, + extent[1] - extent[0] + 1, + ] + direction = np.zeros((3, 3)) + for x in range(3): + for y in range(3): + direction[x][y] = vtk_image["direction"][x * 3 + y] + + pixel_data_array = vtk_image["pointData"]["arrays"][0]["data"] + pixel_js_datatype = pixel_data_array["dataType"] + pixel_type_info = JS_TO_NPY_TYPEMAP.get(pixel_js_datatype) + if not pixel_type_info: + raise ConvertError( + f"Failed to map vtkImageData pixel type {pixel_js_datatype}" + ) + + pixel_data = np.array( + pixel_data_array["values"], dtype=np.dtype(pixel_type_info["dtype"]) + ) + itk_image = itk.GetImageFromArray(np.reshape(pixel_data, dims)) + + # https://discourse.itk.org/t/set-image-direction-from-numpy-array/844/10 + vnlmat = itk.GetVnlMatrixFromArray(direction) + itk_image.GetDirection().GetVnlMatrix().copy_in(vnlmat.data_block()) + itk_image.SetOrigin(vtk_image["origin"]) + itk_image.SetSpacing(vtk_image["spacing"]) + return itk_image + + except Exception as exc: + raise ConvertError("Cannot convert provided vtk_image to an ITK image") from exc + + +def itk_to_vtk_image(itk_image): + """Converts an ITK image to a serialized vtkImageData for vtk.js.""" + if not type(itk_image).__name__.startswith("itkImage"): + raise ConvertError("Provided data is not an ITK image") + + size = list(itk_image.GetLargestPossibleRegion().GetSize()) + values = itk.GetArrayFromImage(itk_image).flatten(order="C") + return { + "vtkClass": "vtkImageData", + "dataDescription": 8, + "direction": list( + itk.GetArrayFromVnlMatrix( + itk_image.GetDirection().GetVnlMatrix().as_matrix() + ).flatten() + ), + "extent": [ + 0, + size[0] - 1, + 0, + size[1] - 1, + 0, + size[2] - 1, + ], + "spacing": list(itk_image.GetSpacing()), + "origin": list(itk_image.GetOrigin()), + "pointData": { + "vtkClass": "vtkDataSetAttributes", + # the index of the only array + "activeScalars": 0, + "arrays": [ + { + "data": { + "vtkClass": "vtkDataArray", + "size": len(values), + "values": values, + "dataType": itk_image_pixel_type_to_js(itk_image), + "numberOfComponents": itk_image.GetNumberOfComponentsPerPixel(), + "name": "Scalars", + } + } + ], + }, + } + + +def convert_vtkjs_to_itk_image(obj): + try: + return vtk_to_itk_image(obj) + except ConvertError: + return None + + +def convert_itk_to_vtkjs_image(obj): + try: + return itk_to_vtk_image(obj) + except ConvertError: + return None diff --git a/server/volview_server/itk_helpers.py b/server/volview_server/itk_helpers.py new file mode 100644 index 000000000..b49b30b0c --- /dev/null +++ b/server/volview_server/itk_helpers.py @@ -0,0 +1,70 @@ +import struct +import numpy as np + +JS_TO_NPY_TYPEMAP = { + "Int8Array": { + "struct": (1, "b"), + "dtype": "int8", + }, + "Int16Array": { + "struct": (2, "h"), + "dtype": "int16", + }, + "Int32Array": { + "struct": (4, "i"), + "dtype": "int32", + }, + "Uint8Array": { + "struct": (1, "B"), + "dtype": "uint8", + }, + "Uint16Array": { + "struct": (2, "H"), + "dtype": "uint16", + }, + "Uint32Array": { + "struct": (4, "I"), + "dtype": "uint32", + }, + "Float32Array": { + "struct": (4, "f"), + "dtype": "float32", + }, + "Float64Array": { + "struct": (8, "d"), + "dtype": "float64", + }, +} + +ITK_COMP_TO_JS_TYPEMAP = { + "SC": "Int8Array", + "UC": "Uint8Array", + "SS": "Int16Array", + "US": "Uint16Array", + "SI": "Int32Array", + "UI": "Uint32Array", + "F": "Float32Array", + "D": "Float64Array", + "B": "Uint8Array", +} + + +def bytebuffer_to_numpy(blob, js_type): + """Converts a JS ArrayBuffer blob to a numpy array.""" + typeinfo = JS_TO_NPY_TYPEMAP[js_type] + size, fmt = typeinfo["struct"] + dtype = np.dtype(typeinfo["dtype"]) + + if len(blob) % size != 0: + raise ValueError("given byte buffer is not aligned to the type") + + full_fmt = "<{0}{1}".format(len(blob) // size, fmt) + return np.array(struct.unpack(full_fmt, blob), dtype=dtype, copy=False) + + +def itk_image_pixel_type_to_js(itk_image): + """Gets the JS pixel type from an ITK image.""" + component_str = repr(itk_image).split("itkImagePython.")[1].split(";")[0][8:] + # TODO handle mangling as per + # https://github.com/InsightSoftwareConsortium/itk-jupyter-widgets/blob/master/itkwidgets/trait_types.py#L49 + return ITK_COMP_TO_JS_TYPEMAP[component_str[:-1]] diff --git a/server/volview_server/rpc_server.py b/server/volview_server/rpc_server.py new file mode 100644 index 000000000..4efa62cff --- /dev/null +++ b/server/volview_server/rpc_server.py @@ -0,0 +1,215 @@ +import asyncio +import enum +import inspect +import traceback +from typing import Any, Callable, Union, List, Generator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field, asdict +from urllib.parse import parse_qs + +import socketio +from aiohttp import web +from socketio.exceptions import ConnectionRefusedError + +RPC_CALL_EVENT = "rpc:call" +RPC_RESULT_EVENT = "rpc:result" +STREAM_CALL_EVENT = "stream:call" +STREAM_RESULT_EVENT = "stream:result" + +NUM_THREADS = 4 +CLIENT_ID_QS = "clientId" +EXPOSE_INFO_ATTR = "_rpc_expose_info" + + +class ExposeType(enum.Enum): + RPC = "rpc" + STREAM = "stream" + + +def validate_rpc_call(data: Any): + if type(data) is not dict: + raise TypeError("data is not a dict") + + rpc_id = data["rpcId"] + if type(rpc_id) is not str: + raise TypeError("rpc ID is not a str") + + name = data["name"] + if type(name) is not str: + raise TypeError("rpc name is not a str") + + args = data.get("args", None) or [] + if type(args) is not list: + raise TypeError("rpc args is not a list") + + return rpc_id, name, args + + +def _add_expose_info(fn: Callable, public_name: str): + expose_type = ExposeType.RPC + if inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn): + expose_type = ExposeType.STREAM + endpoints = getattr(fn, EXPOSE_INFO_ATTR, []) + endpoints.append({"public_name": public_name, "type": expose_type}) + setattr(fn, EXPOSE_INFO_ATTR, endpoints) + return fn + + +def expose(name_or_func: Union[str, Callable]): + if callable(name_or_func): + return _add_expose_info(name_or_func, name_or_func.__name__) + elif type(name_or_func) is str: + return lambda fn: _add_expose_info(fn, name_or_func) + else: + raise Exception("expose(): not given a name or function") + + +@dataclass +class RpcOkResult: + rpcId: str = field(default="", init=False) + ok: bool = field(default=True, init=False) + data: Any = field(default=None) + + +@dataclass +class RpcErrorResult: + rpcId: str = field(default="", init=False) + ok: bool = field(default=False, init=False) + error: str + + +@dataclass +class StreamDataResult(RpcOkResult): + done: bool = field(default=False) + + +RpcResult = Union[RpcOkResult, RpcErrorResult] + + +class RpcServer: + """Implements a bidirectional RPC mechanism.""" + + def __init__(self, ApiClass, num_threads=NUM_THREADS, **kwargs): + self.sio = socketio.AsyncServer(**kwargs) + self.app = web.Application() + self.sio.attach(self.app) + + self.api = ApiClass(self) + + # sid -> client ID + self.clients = {} + + self._thread_pool = ThreadPoolExecutor(num_threads) + + @self.sio.event + def connect(sid: str, environ: dict): + self._on_connect(sid, environ) + + @self.sio.event + def disconnect(sid: str): + self._on_disconnect(sid) + + @self.sio.on(RPC_CALL_EVENT) + async def on_rpc_call(sid: str, data: Any): + await self._on_rpc_call(self.clients[sid], data) + + @self.sio.on(STREAM_CALL_EVENT) + async def on_stream_call(sid: str, data: Any): + await self._on_stream_call(self.clients[sid], data) + + def teardown(self): + """Does internal cleanup.""" + # break circular dependency + self.api = None + + def call(self, client_id: str, rpc_name: str, args: List[Any]): + """Calls an RPC method on a given client. + + Does not support invoking remote generators. + """ + raise NotImplementedError + + def _on_connect(self, sid: str, environ: dict): + qs = parse_qs(environ.get("QUERY_STRING", "")) + (client_id,) = qs.get(CLIENT_ID_QS, [None]) + if not client_id: + raise ConnectionRefusedError("No clientId provided") + + self.clients[sid] = client_id + # add to room based on client_id + self.sio.enter_room(sid, client_id) + + def _on_disconnect(self, sid: str): + client_id = self.clients[sid] + self.sio.leave_room(sid, client_id) + + def _find_exposed_method(self, rpc_name: str, expose_type: str): + """Finds a method annotated with expose info.""" + for attr in dir(self.api): + fn = getattr(self.api, attr) + if not callable(fn): + continue + + endpoints = getattr(fn, EXPOSE_INFO_ATTR, []) + for endpoint in endpoints: + if ( + endpoint.get("type", None) == expose_type + and endpoint.get("public_name", None) == rpc_name + ): + return fn + return None + + async def _on_rpc_call(self, client_id: str, data: Any): + try: + rpc_id, name, args = validate_rpc_call(data) + except TypeError: + print("Received invalid RPC call") + else: + result = await self._try_rpc_call(client_id, name, args) + result.rpcId = rpc_id + await self.sio.emit(RPC_RESULT_EVENT, asdict(result), room=client_id) + + async def _try_rpc_call( + self, client_id: str, name: str, args: List[Any] + ) -> RpcResult: + rpc_fn = self._find_exposed_method(name, ExposeType.RPC) + if not rpc_fn: + return RpcErrorResult(f"{name} is not a registered RPC") + + try: + if inspect.iscoroutinefunction(rpc_fn): + result = await rpc_fn(*args) + else: + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(self._thread_pool, rpc_fn, *args) + return RpcOkResult(result) + except Exception as exc: + traceback.print_exc() + return RpcErrorResult(str(exc)) + + async def _on_stream_call(self, client_id: str, data: Any): + try: + rpc_id, name, args = validate_rpc_call(data) + except TypeError: + print("Received invalid RPC call") + return + + async for result in self._try_generate_stream(client_id, name, args): + result.rpcId = rpc_id + await self.sio.emit(STREAM_RESULT_EVENT, asdict(result), room=client_id) + + async def _try_generate_stream( + self, client_id: str, name: str, args: List[Any] + ) -> Generator[RpcResult, None, None]: + stream_fn = self._find_exposed_method(name, ExposeType.STREAM) + if not stream_fn: + yield RpcErrorResult(f"{name} is not a registered stream RPC") + return + + self._client_id_cvar.set(client_id) + try: + async for data in stream_fn(*args): + yield StreamDataResult(done=False, data=data) + yield StreamDataResult(done=True) + except Exception as exc: + yield RpcErrorResult(str(exc)) diff --git a/src/components/ModulePanel.vue b/src/components/ModulePanel.vue index 47190f862..ff4c2aef8 100644 --- a/src/components/ModulePanel.vue +++ b/src/components/ModulePanel.vue @@ -34,15 +34,22 @@ + +