diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index abcf83bc..762d6cfd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -5,4 +5,5 @@ RUN rustup component add rustfmt clippy RUN apt-get update \ && apt-get install --yes --no-install-recommends \ sqlite3 pre-commit \ + libopencv-dev clang libclang-dev \ && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b260276b..1b4aa889 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,7 +19,8 @@ "ghcr.io/devcontainers/features/common-utils:2": { "username": "none", "upgradePackages": false - } + }, + "ghcr.io/devcontainers/features/git-lfs:1": {} }, // Make sure the files we are mapping into the container exist on the host "initializeCommand": "bash -c 'for i in $HOME/.inputrc; do [ -f $i ] || touch $i; done'", diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index 8923c7e0..626ea28d 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -11,6 +11,7 @@ services: environment: OPA_URL: http://opa:8181 POSTGRES_URL: postgres://postgres:password@postgres + RABBITMQ_URL: amqp://rabbitmq:password@rabbitmq opa: image: docker.io/openpolicyagent/opa:0.53.1 @@ -27,4 +28,10 @@ services: postgres: image: docker.io/library/postgres:15.3-bookworm environment: - POSTGRES_PASSWORD: password \ No newline at end of file + POSTGRES_PASSWORD: password + + rabbitmq: + image: docker.io/library/rabbitmq:3.12.1 + environment: + RABBITMQ_DEFAULT_USER: rabbitmq + RABBITMQ_DEFAULT_PASS: password diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ae46686a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +chimp_chomp/chimp.onnx filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index b3237da6..b1984869 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -13,6 +13,11 @@ jobs: - name: Checkout source uses: actions/checkout@v3.5.2 + - name: Install dependencies + uses: awalsh128/cache-apt-pkgs-action@v1.3.0 + with: + packages: libopencv-dev clang libclang-dev + - name: Install stable toolchain uses: actions-rs/toolchain@v1.0.6 with: @@ -50,6 +55,11 @@ jobs: - name: Checkout source uses: actions/checkout@v3.5.2 + - name: Install dependencies + uses: awalsh128/cache-apt-pkgs-action@v1.3.0 + with: + packages: libopencv-dev clang libclang-dev + - name: Install stable toolchain uses: actions-rs/toolchain@v1.0.6 with: diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index a2f2d8d3..51c1e3bb 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -10,13 +10,15 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository strategy: matrix: - service: + target: + - chimp_chomp - soakdb_sync - pin_packing + max-parallel: 1 runs-on: ubuntu-latest steps: - name: Generate Image Name - run: echo IMAGE_REPOSITORY=ghcr.io/$(tr '[:upper:]' '[:lower:]' <<< "${{ github.repository }}")-${{ matrix.service }} >> $GITHUB_ENV + run: echo IMAGE_REPOSITORY=ghcr.io/$(tr '[:upper:]' '[:lower:]' <<< "${{ github.repository }}")-${{ matrix.target }} >> $GITHUB_ENV - name: Log in to GitHub Docker Registry if: github.event_name != 'pull_request' @@ -41,8 +43,7 @@ jobs: - name: Build Image uses: docker/build-push-action@v4.0.0 with: - build-args: | - SERVICE=${{ matrix.service }} + target: ${{ matrix.target }} push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} load: ${{ !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags')) }} tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 48e15ecf..4c51606f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,6 +13,11 @@ jobs: - name: Checkout source uses: actions/checkout@v3.5.2 + - name: Install dependencies + uses: awalsh128/cache-apt-pkgs-action@v1.3.0 + with: + packages: libopencv-dev clang libclang-dev + - name: Install nightly toolchain uses: actions-rs/toolchain@v1.0.6 with: diff --git a/Cargo.lock b/Cargo.lock index 7cafbae6..753074c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,54 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56fc6cf8dc8c4158eed8649f9b8b0ea1518eb62b544fe9490d66fa0b349eafe9" +[[package]] +name = "amq-protocol" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d40d8b2465c7959dd40cee32ba6ac334b5de57e9fca0cc756759894a4152a5d" +dependencies = [ + "amq-protocol-tcp", + "amq-protocol-types", + "amq-protocol-uri", + "cookie-factory", + "nom", + "serde", +] + +[[package]] +name = "amq-protocol-tcp" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb2100adae7da61953a2c3a01935d86caae13329fadce3333f524d6d6ce12e2" +dependencies = [ + "amq-protocol-uri", + "tcp-stream", + "tracing", +] + +[[package]] +name = "amq-protocol-types" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "156ff13c8a3ced600b4e54ed826a2ae6242b6069d00dd98466827cef07d3daff" +dependencies = [ + "cookie-factory", + "nom", + "serde", + "serde_json", +] + +[[package]] +name = "amq-protocol-uri" +version = "7.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "751bbd7d440576066233e740576f1b31fdc6ab86cfabfbd48c548de77eca73e4" +dependencies = [ + "amq-protocol-types", + "percent-encoding", + "url", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -134,6 +182,72 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "anyhow" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-channel" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf46fee83e5ccffc220104713af3292ff9bc7c64c7de289f66dae8e38d826833" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-executor" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa3dc5f2a8564f07759c008b9109dc0d39de92a88d5588b8a5036d286383afb" +dependencies = [ + "async-lock", + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b6f5d7df27bd294849f8eec66ecfc63d11814df7a4f5d74168a2394467b776" +dependencies = [ + "async-channel", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-global-executor-trait" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dd14c5a15affd2abcff50d84efd4009ada28a860f01c14f9d654f3e81b3f75" +dependencies = [ + "async-global-executor", + "async-trait", + "executor-trait", +] + [[package]] name = "async-graphql" version = "5.0.10" @@ -226,6 +340,47 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite", + "log", + "parking", + "polling", + "rustix", + "slab", + "socket2", + "waker-fn", +] + +[[package]] +name = "async-lock" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa24f727524730b077666307f2734b4a1a1c57acb79193127dcc8914d5242dd7" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6012d170ad00de56c9ee354aef2e358359deb1ec504254e0e5a3774771de0e" +dependencies = [ + "async-io", + "async-trait", + "futures-core", + "reactor-trait", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -248,6 +403,12 @@ dependencies = [ "syn 2.0.25", ] +[[package]] +name = "async-task" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc7ab41815b3c653ccd2978ec3255c81349336702dfdf62ee6f7069b12a3aae" + [[package]] name = "async-trait" version = "0.1.68" @@ -268,6 +429,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3" + [[package]] name = "autocfg" version = "1.1.0" @@ -350,7 +517,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.6.2", "object", "rustc-demangle", ] @@ -395,6 +562,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77231a1c8f801696fc0123ec6150ce92cffb8e164a02afb9c8ddee0e9b65ad65" +dependencies = [ + "async-channel", + "async-lock", + "async-task", + "atomic-waker", + "fastrand", + "futures-lite", + "log", +] + [[package]] name = "bumpalo" version = "3.13.0" @@ -421,6 +603,9 @@ name = "cc" version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +dependencies = [ + "jobserver", +] [[package]] name = "cfg-if" @@ -428,6 +613,37 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chimp_chomp" +version = "0.1.0" +dependencies = [ + "anyhow", + "approx", + "chimp_protocol", + "clap 4.3.14", + "derive_more", + "dotenvy", + "futures", + "futures-timer", + "itertools", + "lapin", + "ndarray", + "opencv", + "ort", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "chimp_protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "uuid", +] + [[package]] name = "chrono" version = "0.4.26" @@ -462,6 +678,26 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "clang" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c044c781163c001b913cd018fc95a628c50d0d2dfea8bca77dad71edb16e37" +dependencies = [ + "clang-sys", + "libc", +] + +[[package]] +name = "clang-sys" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f" +dependencies = [ + "glob", + "libc", +] + [[package]] name = "clap" version = "3.2.25" @@ -545,12 +781,27 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "concurrent-queue" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ec6771ecfa0762d24683ee5a32ad78487a3d3afdc0fb8cae19d2c5deb50b7c" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + [[package]] name = "core-foundation" version = "0.9.3" @@ -576,6 +827,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.8" @@ -690,12 +950,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" + [[package]] name = "either" version = "1.8.1" @@ -744,6 +1016,15 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "executor-trait" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a1052dd43212a7777ec6a69b117da52f5e52f07aec47d00c1a2b33b85d06b08" +dependencies = [ + "async-trait", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -753,6 +1034,28 @@ dependencies = [ "instant", ] +[[package]] +name = "filetime" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.2.16", + "windows-sys 0.48.0", +] + +[[package]] +name = "flate2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +dependencies = [ + "crc32fast", + "miniz_oxide 0.7.1", +] + [[package]] name = "flume" version = "0.10.14" @@ -762,7 +1065,7 @@ dependencies = [ "futures-core", "futures-sink", "pin-project", - "spin", + "spin 0.9.8", ] [[package]] @@ -845,7 +1148,7 @@ checksum = "a604f7a68fbf8103337523b1fadc8ade7361ee3f112f7c680ad179651616aed5" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.11.2", ] [[package]] @@ -854,6 +1157,21 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + [[package]] name = "futures-macro" version = "0.3.28" @@ -877,6 +1195,12 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +[[package]] +name = "futures-timer" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" + [[package]] name = "futures-util" version = "0.3.28" @@ -922,6 +1246,12 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "graphql_endpoints" version = "0.1.0" @@ -1267,6 +1597,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.64" @@ -1276,6 +1615,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lapin" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc13beaa09eed710f406201f46b961345b4d061dd90ec3d3ccc70721e70342a" +dependencies = [ + "amq-protocol", + "async-global-executor-trait", + "async-reactor-trait", + "async-trait", + "executor-trait", + "flume", + "futures-core", + "futures-io", + "parking_lot 0.12.1", + "pinky-swear", + "reactor-trait", + "serde", + "tracing", + "waker-fn", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1409,6 +1770,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" +[[package]] +name = "matrixmultiply" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090126dc04f95dc0d1c1c91f61bdd474b3930ca064c1edc8a849da2c6cbe1e77" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "md-5" version = "0.10.5" @@ -1445,6 +1816,15 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + [[package]] name = "mio" version = "0.8.8" @@ -1470,7 +1850,7 @@ dependencies = [ "log", "memchr", "mime", - "spin", + "spin 0.9.8", "version_check", ] @@ -1492,6 +1872,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "rawpointer", +] + [[package]] name = "nom" version = "7.1.3" @@ -1512,6 +1905,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-complex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -1558,6 +1970,39 @@ dependencies = [ "url", ] +[[package]] +name = "opencv" +version = "0.82.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79290f5f138b26637cae0ae243d77de871a096e334d3fca22f5ddf31ab6f4cc5" +dependencies = [ + "cc", + "dunce", + "jobserver", + "libc", + "num-traits", + "once_cell", + "opencv-binding-generator", + "pkg-config", + "semver", + "shlex", + "vcpkg", +] + +[[package]] +name = "opencv-binding-generator" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5f640bda28b478629f525e8525601586a2a2b9403a4b8f2264fa5fcfebe6be" +dependencies = [ + "clang", + "clang-sys", + "dunce", + "once_cell", + "percent-encoding", + "regex", +] + [[package]] name = "openssl" version = "0.10.55" @@ -1602,6 +2047,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ort" +version = "1.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562aedddfc14391badc2e527fd568164bd2fb108e2572cd3fe0b53ed440ec439" +dependencies = [ + "flate2", + "lazy_static", + "libc", + "ndarray", + "tar", + "thiserror", + "tracing", + "ureq", + "vswhom", + "winapi", + "zip", +] + [[package]] name = "os_str_bytes" version = "6.5.1" @@ -1637,6 +2101,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parking" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f2252c834a40ed9bb5422029649578e63aa341ac401f74e719dd1afda8394e" + [[package]] name = "parking_lot" version = "0.11.2" @@ -1645,7 +2115,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.8", ] [[package]] @@ -1662,6 +2142,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.3.5", + "smallvec", + "windows-targets", +] + [[package]] name = "parse-zoneinfo" version = "0.3.0" @@ -1817,12 +2310,40 @@ dependencies = [ "uuid", ] +[[package]] +name = "pinky-swear" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d894b67aa7a4bf295db5e85349078c604edaa6fa5c8721e8eca3c7729a27f2ac" +dependencies = [ + "doc-comment", + "flume", + "parking_lot 0.12.1", + "tracing", +] + [[package]] name = "pkg-config" version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1911,6 +2432,23 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "438a4293e4d097556730f4711998189416232f009c137389e0f961d2bc0ddc58" +dependencies = [ + "async-trait", + "futures-core", + "futures-io", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -2009,6 +2547,21 @@ dependencies = [ "winreg", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -2038,6 +2591,37 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rustls" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e32ca28af694bc1bbf399c33a516dbdf1c90090b8ab23c2bc24f834aa2247f5f" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" +dependencies = [ + "base64 0.21.2", +] + +[[package]] +name = "rustls-webpki" +version = "0.100.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.12" @@ -2065,6 +2649,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sea-orm" version = "0.11.3" @@ -2267,9 +2861,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3" +checksum = "0f1e14e89be7aa4c4b78bdbdc9eb5bf8517829a600ae8eaa39a6e1d960b5185c" dependencies = [ "itoa", "ryu", @@ -2328,6 +2922,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" + [[package]] name = "siphasher" version = "0.3.10" @@ -2393,6 +2993,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.8" @@ -2563,6 +3169,28 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "tar" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b55807c0344e1e6c04d7c965f5289c39a8d94ae23ed5c0b57aabac549f871c6" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tcp-stream" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1322b18a9e329ba45e4430b19543045b85cd1dcb2892e77d27ab471ba2039bd1" +dependencies = [ + "cfg-if", + "native-tls", + "rustls-pemfile", +] + [[package]] name = "tempfile" version = "3.6.0" @@ -2896,6 +3524,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "ureq" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9" +dependencies = [ + "base64 0.21.2", + "log", + "once_cell", + "rustls", + "rustls-webpki", + "url", + "webpki-roots", +] + [[package]] name = "url" version = "2.4.0" @@ -2947,6 +3596,32 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" + [[package]] name = "want" version = "0.3.1" @@ -3038,6 +3713,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" +dependencies = [ + "rustls-webpki", +] + [[package]] name = "whoami" version = "1.4.1" @@ -3219,3 +3903,24 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] + +[[package]] +name = "xattr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +dependencies = [ + "libc", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] diff --git a/Cargo.toml b/Cargo.toml index 19879be0..38364352 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [workspace] members = [ + "chimp_chomp", + "chimp_protocol", "graphql_endpoints", "graphql_event_broker", "opa_client", @@ -33,4 +35,4 @@ tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread"] } tracing = { version = "0.1.37" } tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = { version = "2.4.0" } -uuid = { version = "1.4.1" } +uuid = { version = "1.4.1", features = ["v4"] } diff --git a/Dockerfile b/Dockerfile index 3ba1c2e9..0b5a536f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,25 @@ FROM docker.io/library/rust:1.71.0-bullseye AS build WORKDIR /app +RUN apt-get update \ + && apt-get install -y \ + libopencv-dev clang libclang-dev + # Build dependencies COPY Cargo.toml Cargo.lock ./ +COPY chimp_chomp/Cargo.toml chimp_chomp/Cargo.toml +COPY chimp_protocol/Cargo.toml chimp_protocol/Cargo.toml COPY graphql_endpoints/Cargo.toml graphql_endpoints/Cargo.toml COPY graphql_event_broker/Cargo.toml graphql_event_broker/Cargo.toml COPY opa_client/Cargo.toml opa_client/Cargo.toml COPY pin_packing/Cargo.toml pin_packing/Cargo.toml COPY soakdb_io/Cargo.toml soakdb_io/Cargo.toml COPY soakdb_sync/Cargo.toml soakdb_sync/Cargo.toml -RUN mkdir graphql_endpoints/src \ +RUN mkdir chimp_chomp/src \ + && echo "fn main() {}" > chimp_chomp/src/main.rs \ + && mkdir chimp_protocol/src \ + && touch chimp_protocol/src/lib.rs \ + && mkdir graphql_endpoints/src \ && touch graphql_endpoints/src/lib.rs \ && mkdir graphql_event_broker/src \ && touch graphql_event_broker/src/lib.rs \ @@ -26,7 +36,9 @@ RUN mkdir graphql_endpoints/src \ # Build workspace crates COPY . /app -RUN touch graphql_endpoints/src/lib.rs \ +RUN touch chimp_chomp/src/lib.rs \ + && touch chimp_protocol/src/lib.rs \ + && touch graphql_endpoints/src/lib.rs \ && touch graphql_event_broker/src/lib.rs \ && touch opa_client/src/lib.rs \ && touch pin_packing/src/main.rs \ @@ -34,9 +46,29 @@ RUN touch graphql_endpoints/src/lib.rs \ && touch soakdb_sync/src/main.rs \ && cargo build --release -FROM gcr.io/distroless/cc -ARG SERVICE +# Collate dynamically linked shared objects for chimp_chomp +RUN mkdir /chimp_chomp_libraries \ + && cp \ + $(ldd /app/target/release/chimp_chomp | grep -o '/.*\.so\S*') \ + /app/target/release/libonnxruntime.so.1.14.1 \ + /chimp_chomp_libraries + +FROM gcr.io/distroless/cc as chimp_chomp + +COPY --from=build /chimp_chomp_libraries/* /lib +COPY --from=build /app/target/release/chimp.onnx /chimp.onnx +COPY --from=build /app/target/release/chimp_chomp /chimp_chomp + +ENTRYPOINT ["./chimp_chomp"] + +FROM gcr.io/distroless/cc as pin_packing + +COPY --from=build /app/target/release/pin_packing /pin_packing + +ENTRYPOINT ["./pin_packing"] + +FROM gcr.io/distroless/cc as soakdb_sync -COPY --from=build /app/target/release/${SERVICE} /service +COPY --from=build /app/target/release/soakdb_sync /soakdb_sync -ENTRYPOINT ["./service"] +ENTRYPOINT ["./soakdb_sync"] diff --git a/chimp_chomp/Cargo.toml b/chimp_chomp/Cargo.toml new file mode 100644 index 00000000..fd25a200 --- /dev/null +++ b/chimp_chomp/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "chimp_chomp" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { version = "1.0.72" } +chimp_protocol = { path = "../chimp_protocol" } +clap = { workspace = true } +derive_more = { workspace = true } +dotenvy = { workspace = true } +futures = { version = "0.3.28" } +futures-timer = { version = "3.0.2" } +itertools = { workspace = true } +lapin = { version = "2.2.1", default-features = false, features = [ + "native-tls", +] } +ndarray = { version = "0.15.6" } +opencv = { version = "0.82.1", default-features = false, features = [ + "imgproc", + "imgcodecs", +] } +ort = { version = "1.14.8", default-features = false, features = [ + "download-binaries", + "copy-dylibs", +] } +tokio = { workspace = true, features = ["sync"] } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +approx = { version = "0.5.1" } diff --git a/chimp_chomp/README.md b/chimp_chomp/README.md new file mode 100644 index 00000000..37dc5feb --- /dev/null +++ b/chimp_chomp/README.md @@ -0,0 +1,3 @@ +# CHiMP Worker + +This worker steals jobs from a RabbitMQ queue, retrieves images, performs batch inference on them using the CHiMP neural network and returns results on another RabbitMQ queue. The worker is intended to be deployed as a autoscaled to zero service. diff --git a/chimp_chomp/build.rs b/chimp_chomp/build.rs new file mode 100644 index 00000000..5fbcfc66 --- /dev/null +++ b/chimp_chomp/build.rs @@ -0,0 +1,16 @@ +use std::{env, fs::copy, path::PathBuf}; + +const MODEL_FILE: &str = "chimp.onnx"; + +fn main() { + let model_src = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join(MODEL_FILE); + let model_dst = PathBuf::from(env::var("OUT_DIR").unwrap()) + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join(MODEL_FILE); + copy(model_src, model_dst).unwrap(); +} diff --git a/chimp_chomp/chimp.onnx b/chimp_chomp/chimp.onnx new file mode 100644 index 00000000..82724c1c --- /dev/null +++ b/chimp_chomp/chimp.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62905cc5a5a2ed3118467ee5d44c435786eb542be9cb31667441b2c78622c6a3 +size 176151256 diff --git a/chimp_chomp/src/image_loading.rs b/chimp_chomp/src/image_loading.rs new file mode 100644 index 00000000..86e42c48 --- /dev/null +++ b/chimp_chomp/src/image_loading.rs @@ -0,0 +1,100 @@ +use anyhow::Context; +use derive_more::Deref; +use ndarray::{Array, Ix3}; +use opencv::{ + core::{Size_, Vec3f, CV_32FC3}, + imgcodecs::{imread, IMREAD_COLOR}, + imgproc::{cvt_color, resize, COLOR_BGR2GRAY, COLOR_BGR2RGB, INTER_LINEAR}, + prelude::{Mat, MatTraitConst}, +}; +use std::path::Path; + +/// A grayscale image of the well in [W, H, C] format. +#[derive(Debug, Deref)] +pub struct WellImage(pub Mat); + +/// A RGB image of the well in [C, W, H] format. +#[derive(Debug, Deref)] +pub struct ChimpImage(Array); + +/// Converts an image from a [`Mat`] in BGR and ordered in [W, H, C] to a [`Array`] in RGB and ordered in [C, W, H] and resizes it to the input dimensions of the model. +fn prepare_chimp(image: &Mat, width: i32, height: i32) -> ChimpImage { + let mut resized_image = Mat::default(); + resize( + &image, + &mut resized_image, + Size_ { width, height }, + 0.0, + 0.0, + INTER_LINEAR, + ) + .unwrap(); + + let mut resized_rgb_image = Mat::default(); + cvt_color(&resized_image, &mut resized_rgb_image, COLOR_BGR2RGB, 0).unwrap(); + let mut resized_rgb_f32_image = Mat::default(); + + resized_rgb_image + .convert_to( + &mut resized_rgb_f32_image, + CV_32FC3, + f64::from(std::u8::MAX).recip(), + 0.0, + ) + .unwrap(); + let chimp_image = Array::from_iter( + resized_rgb_f32_image + .iter::() + .unwrap() + .flat_map(|(_, pixel)| pixel), + ) + .into_shape(( + resized_rgb_f32_image.mat_size()[0] as usize, + resized_rgb_f32_image.mat_size()[1] as usize, + resized_rgb_f32_image.channels() as usize, + )) + .unwrap() + .permuted_axes((2, 0, 1)) + .as_standard_layout() + .to_owned(); + + ChimpImage(chimp_image) +} + +/// Converts an image from BGR to grayscale. +fn prepare_well(image: &Mat) -> WellImage { + let mut well_image = Mat::default(); + cvt_color(&image, &mut well_image, COLOR_BGR2GRAY, 0).unwrap(); + WellImage(well_image) +} + +/// Reads an image from file. +/// +/// Returns an [`anyhow::Error`] if the image could not be read or is empty. +fn read_image(path: impl AsRef) -> Result { + let image = imread( + path.as_ref() + .to_str() + .context("Image path contains non-UTF8 characters")?, + IMREAD_COLOR, + )?; + if image.empty() { + return Err(anyhow::Error::msg("No image data was loaded")); + } + Ok(image) +} + +/// Reads an image from file and prepares both a [`ChimpImage`] and a [`WellImage`]. +/// +/// Returns an [`anyhow::Error`] if the image could not be read or is empty. +pub fn load_image( + path: impl AsRef, + chimp_width: u32, + chimp_height: u32, +) -> Result<(ChimpImage, WellImage), anyhow::Error> { + let image = read_image(path)?; + let well_image = prepare_well(&image); + let chimp_image = prepare_chimp(&image, chimp_width as i32, chimp_height as i32); + + Ok((chimp_image, well_image)) +} diff --git a/chimp_chomp/src/inference.rs b/chimp_chomp/src/inference.rs new file mode 100644 index 00000000..afd07afc --- /dev/null +++ b/chimp_chomp/src/inference.rs @@ -0,0 +1,138 @@ +use crate::image_loading::ChimpImage; +use anyhow::Context; +use chimp_protocol::Job; +use itertools::{izip, Itertools}; +use ndarray::{Array1, Array2, Array3, Axis, Ix1, Ix2, Ix4}; +use ort::{ + tensor::{FromArray, InputTensor}, + Environment, ExecutionProvider, GraphOptimizationLevel, Session, SessionBuilder, +}; +use std::{env::current_exe, ops::Deref, sync::Arc}; +use tokio::sync::mpsc::{error::TryRecvError, Receiver, UnboundedSender}; + +/// The raw box predictor output of a MaskRCNN. +pub type BBoxes = Array2; +/// The raw label output of a MaskRCNN. +pub type Labels = Array1; +/// The raw scores output of a MaskRCNN. +pub type Scores = Array1; +/// The raw masks output of a MaskRCNN. +pub type Masks = Array3; + +/// Starts an inference session by setting up the ONNX Runtime environment and loading the model. +/// +/// Returns an [`anyhow::Error`] if the environment could not be built or if the model could not be loaded. +pub fn setup_inference_session() -> Result { + let environment = Arc::new( + Environment::builder() + .with_name("CHiMP") + .with_execution_providers([ExecutionProvider::cpu()]) + .build()?, + ); + Ok(SessionBuilder::new(&environment)? + .with_optimization_level(GraphOptimizationLevel::Level3)? + .with_model_from_file( + current_exe()? + .parent() + .context("Executable has no parent directory")? + .join("chimp.onnx"), + )?) +} + +/// Performs inference on a batch of images, dummy images are used to pad the tesnor if underfull. +/// +/// Returns a set of predictions, where each instances corresponds to the an input image, order is maintained. +fn do_inference( + session: &Session, + images: &[ChimpImage], + batch_size: usize, +) -> Vec<(BBoxes, Labels, Scores, Masks)> { + let batch_images = images + .iter() + .map(|image| image.deref().view()) + .cycle() + .take(batch_size) + .collect::>(); + let input = InputTensor::from_array(ndarray::stack(Axis(0), &batch_images).unwrap().into_dyn()); + let outputs = session.run(vec![input]).unwrap(); + outputs + .into_iter() + .take(images.len() * 4) + .tuples() + .map(|(bboxes, labels, scores, masks)| { + let bboxes = bboxes + .try_extract::() + .unwrap() + .view() + .to_owned() + .into_dimensionality::() + .unwrap(); + let labels = labels + .try_extract::() + .unwrap() + .view() + .to_owned() + .into_dimensionality::() + .unwrap(); + let scores = scores + .try_extract::() + .unwrap() + .view() + .to_owned() + .into_dimensionality::() + .unwrap(); + let masks = masks + .try_extract::() + .unwrap() + .view() + .to_owned() + .into_dimensionality::() + .unwrap() + .remove_axis(Axis(1)); + + (bboxes, labels, scores, masks) + }) + .collect() +} + +/// Listens to a [`Receiver`] for instances of [`ChimpImage`] and performs batch inference on these. +/// +/// Each pass, all available images in the [`tokio::sync::mpsc::channel`] - up to the batch size - are taken and passed to the model for inference. +/// Model predictions are sent over a [`tokio::sync::mpsc::unbounded_channel`]. +pub async fn inference_worker( + session: Session, + batch_size: usize, + mut image_rx: Receiver<(ChimpImage, Job)>, + prediction_tx: UnboundedSender<(BBoxes, Labels, Scores, Masks, Job)>, +) { + let mut images = Vec::new(); + let mut jobs = Vec::::new(); + loop { + let (image, job) = image_rx.recv().await.unwrap(); + images.push(image); + jobs.push(job); + while images.len() < batch_size { + match image_rx.try_recv() { + Ok((image, job)) => { + images.push(image); + jobs.push(job); + Ok(()) + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => Err(TryRecvError::Disconnected), + } + .unwrap(); + } + println!("CHiMP Inference ({}): {:?}", images.len(), jobs); + let predictions = do_inference(&session, &images, batch_size); + izip!(predictions.into_iter(), jobs.iter()).for_each( + |((bboxes, labels, scores, masks), job)| { + prediction_tx + .send((bboxes, labels, scores, masks, job.clone())) + .unwrap(); + }, + ); + images.clear(); + jobs.clear(); + } +} diff --git a/chimp_chomp/src/jobs.rs b/chimp_chomp/src/jobs.rs new file mode 100644 index 00000000..a82a6380 --- /dev/null +++ b/chimp_chomp/src/jobs.rs @@ -0,0 +1,125 @@ +use crate::{ + image_loading::{load_image, ChimpImage, WellImage}, + postprocessing::Contents, +}; +use chimp_protocol::{Circle, Job, Response}; +use futures::StreamExt; +use lapin::{ + options::{BasicAckOptions, BasicConsumeOptions, BasicPublishOptions}, + types::FieldTable, + BasicProperties, Channel, Connection, Consumer, +}; +use tokio::sync::mpsc::{OwnedPermit, UnboundedSender}; +use url::Url; +use uuid::Uuid; + +/// Creates a RabbitMQ [`Connection`] with [`Default`] [`lapin::ConnectionProperties`]. +/// +/// Returns a [`lapin::Error`] if a connection could not be established. +pub async fn setup_rabbitmq_client(address: Url) -> Result { + lapin::Connection::connect(address.as_str(), lapin::ConnectionProperties::default()).await +} + +/// Joins a RabbitMQ channel, creating a [`Consumer`] with [`Default`] [`BasicConsumeOptions`] and [`FieldTable`]. +/// The consumer tag is generated following the format `chimp_chomp_${`[`Uuid::new_v4`]`}`. +/// +/// Returns a [`lapin::Error`] if the requested channel is not available. +pub async fn setup_job_consumer( + rabbitmq_channel: Channel, + channel: impl AsRef, +) -> Result { + let worker_id = Uuid::new_v4(); + let worker_tag = format!("chimp_chomp_{worker_id}"); + rabbitmq_channel + .basic_consume( + channel.as_ref(), + &worker_tag, + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await +} + +/// Reads a message from the [`lapin::Consumer`] then loads and prepares the requested image for downstream processing. +/// +/// An [`OwnedPermit`] to send to the chimp [`tokio::sync::mpsc::channel`] is required such that backpressure is be propagated to message consumption. +/// +/// The prepared images are sent over a [`tokio::sync::mpsc::channel`] and [`tokio::sync::mpsc::unbounded_channel`] if sucessful. +/// An [`anyhow::Error`] is sent if the image could not be read or is empty. +pub async fn consume_job( + mut consumer: Consumer, + input_width: u32, + input_height: u32, + chimp_permit: OwnedPermit<(ChimpImage, Job)>, + well_image_tx: UnboundedSender<(WellImage, Job)>, + error_tx: UnboundedSender<(anyhow::Error, Job)>, +) { + let delivery = consumer.next().await.unwrap().unwrap(); + delivery.ack(BasicAckOptions::default()).await.unwrap(); + + let job = Job::from_slice(&delivery.data).unwrap(); + println!("Consumed Job: {job:?}"); + + match load_image(job.file.clone(), input_width, input_height) { + Ok((chimp_image, well_image)) => { + chimp_permit.send((chimp_image, job.clone())); + well_image_tx + .send((well_image, job)) + .map_err(|_| anyhow::Error::msg("Could not send well image")) + .unwrap() + } + Err(err) => error_tx.send((err, job)).unwrap(), + }; +} + +/// Takes the results of postprocessing and well centering and publishes a [`Response::Success`] to the RabbitMQ [`Channel`] provided by the [`Job`]. +pub async fn produce_response( + contents: Contents, + well_location: Circle, + job: Job, + rabbitmq_channel: Channel, +) { + println!("Producing response for: {job:?}"); + rabbitmq_channel + .basic_publish( + "", + &job.predictions_channel, + BasicPublishOptions::default(), + &Response::Success { + job_id: job.id, + insertion_point: contents.insertion_point, + well_location, + drop: contents.drop, + crystals: contents.crystals, + } + .to_vec() + .unwrap(), + BasicProperties::default(), + ) + .await + .unwrap() + .await + .unwrap(); +} + +/// Takes an error generated in one of the prior stages and publishes a [`Response::Failure`] to the RabbitMQ [`Channel`] provided by the [`Job`]. +pub async fn produce_error(error: anyhow::Error, job: Job, rabbitmq_channel: Channel) { + println!("Producing error for: {job:?}"); + rabbitmq_channel + .basic_publish( + "", + &job.predictions_channel, + BasicPublishOptions::default(), + &Response::Failure { + job_id: job.id, + error: error.to_string(), + } + .to_vec() + .unwrap(), + BasicProperties::default(), + ) + .await + .unwrap() + .await + .unwrap(); +} diff --git a/chimp_chomp/src/main.rs b/chimp_chomp/src/main.rs new file mode 100644 index 00000000..3083d86e --- /dev/null +++ b/chimp_chomp/src/main.rs @@ -0,0 +1,151 @@ +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![warn(clippy::missing_docs_in_private_items)] +#![doc=include_str!("../README.md")] + +/// Utilities for loading images. +mod image_loading; +/// Neural Netowrk inference with [`ort`]. +mod inference; +/// RabbitMQ [`Job`] queue consumption and [`Response`] publishing. +mod jobs; +/// Neural Network inference postprocessing with optimal insertion point finding. +mod postprocessing; +/// Well localisation. +mod well_centering; + +use crate::{ + inference::{inference_worker, setup_inference_session}, + jobs::{ + consume_job, produce_error, produce_response, setup_job_consumer, setup_rabbitmq_client, + }, + postprocessing::inference_postprocessing, + well_centering::well_centering, +}; +use chimp_protocol::{Circle, Job}; +use clap::Parser; +use futures::future::Either; +use futures_timer::Delay; +use postprocessing::Contents; +use std::{collections::HashMap, time::Duration}; +use tokio::{select, spawn, task::JoinSet}; +use url::Url; + +/// An inference worker for the Crystal Hits in My Plate (CHiMP) neural network. +#[derive(Debug, Parser)] +#[command(author, version, about, long_about=None)] +struct Cli { + /// The URL of the RabbitMQ server. + rabbitmq_url: Url, + /// The RabbitMQ channel on which jobs are assigned. + rabbitmq_channel: String, + /// The duration (in milliseconds) to wait after completing all jobs before shutting down. + #[arg(long)] + timeout: Option, + /// The number of worker threads to use + #[arg(long)] + threads: Option, +} + +fn main() { + dotenvy::dotenv().ok(); + let args = Cli::parse(); + opencv::core::set_num_threads(0).unwrap(); + + let runtime = { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.enable_all(); + if let Some(threads) = args.threads { + builder.worker_threads(threads); + } + builder.build().unwrap() + }; + runtime.block_on(run(args)); +} + +#[allow(clippy::missing_docs_in_private_items)] +async fn run(args: Cli) { + let session = setup_inference_session().unwrap(); + let input_width = session.inputs[0].dimensions[3].unwrap(); + let input_height = session.inputs[0].dimensions[2].unwrap(); + let batch_size = session.inputs[0].dimensions[0].unwrap().try_into().unwrap(); + + let rabbitmq_client = setup_rabbitmq_client(args.rabbitmq_url).await.unwrap(); + let job_channel = rabbitmq_client.create_channel().await.unwrap(); + let response_channel = rabbitmq_client.create_channel().await.unwrap(); + let job_consumer = setup_job_consumer(job_channel, args.rabbitmq_channel) + .await + .unwrap(); + + let (chimp_image_tx, chimp_image_rx) = tokio::sync::mpsc::channel(batch_size); + let (well_image_tx, mut well_image_rx) = tokio::sync::mpsc::unbounded_channel(); + let (well_location_tx, mut well_location_rx) = + tokio::sync::mpsc::unbounded_channel::<(Circle, Job)>(); + let (prediction_tx, mut prediction_rx) = tokio::sync::mpsc::unbounded_channel(); + let (contents_tx, mut contents_rx) = tokio::sync::mpsc::unbounded_channel::<(Contents, Job)>(); + let (error_tx, mut error_rx) = tokio::sync::mpsc::unbounded_channel(); + + spawn(inference_worker( + session, + batch_size, + chimp_image_rx, + prediction_tx, + )); + + let mut tasks = JoinSet::new(); + + let mut well_locations = HashMap::new(); + let mut well_contents = HashMap::new(); + + loop { + let timeout = if let Some(timeout) = args.timeout { + Either::Left(Delay::new(Duration::from_millis(timeout))) + } else { + Either::Right(std::future::pending()) + }; + + select! { + biased; + + Some((error, job)) = error_rx.recv() => { + tasks.spawn(produce_error(error, job, response_channel.clone())); + } + + Some((well_location, job)) = well_location_rx.recv() => { + if let Some(contents) = well_contents.remove(&job.id) { + tasks.spawn(produce_response(contents, well_location, job, response_channel.clone())); + } else { + well_locations.insert(job.id, well_location); + } + } + + Some((contents, job)) = contents_rx.recv() => { + if let Some(well_location) = well_locations.remove(&job.id) { + tasks.spawn(produce_response(contents, well_location, job, response_channel.clone())); + } else { + well_contents.insert(job.id, contents); + } + } + + chimp_permit = chimp_image_tx.clone().reserve_owned() => { + let chimp_permit = chimp_permit.unwrap(); + tasks.spawn(consume_job(job_consumer.clone(), input_width, input_height, chimp_permit, well_image_tx.clone(), error_tx.clone())); + } + + Some((well_image, job)) = well_image_rx.recv() => { + tasks.spawn(well_centering(well_image, job, well_location_tx.clone(), error_tx.clone())); + } + + Some((bboxes, labels, _, masks, job)) = prediction_rx.recv() => { + tasks.spawn(inference_postprocessing(bboxes, labels, masks, job, contents_tx.clone(), error_tx.clone())); + } + + _ = timeout => { + println!("Stopping: No jobs processed for {}ms", args.timeout.unwrap()); + break; + } + + else => break + } + } +} diff --git a/chimp_chomp/src/postprocessing.rs b/chimp_chomp/src/postprocessing.rs new file mode 100644 index 00000000..95c04702 --- /dev/null +++ b/chimp_chomp/src/postprocessing.rs @@ -0,0 +1,188 @@ +use crate::inference::{BBoxes, Labels, Masks}; +use anyhow::Context; +use chimp_protocol::{BBox, Job, Point}; +use itertools::izip; +use ndarray::{Array2, ArrayView, ArrayView2, Ix1}; +use opencv::{ + core::CV_8U, + imgproc::{distance_transform, DIST_L1, DIST_MASK_3}, + prelude::{Mat, MatTraitConst}, +}; +use tokio::sync::mpsc::UnboundedSender; + +/// The predicted contents of a well image. +#[derive(Debug)] +pub struct Contents { + /// The optimal point at which solvent should be inserted. + pub insertion_point: Point, + /// A bounding box enclosing the drop of solution. + pub drop: BBox, + /// A set of bounding boxes enclosing each crystal in the drop. + pub crystals: Vec, +} + +/// The threshold to apply to the raw MaskRCNN [`Masks`] to generate a binary mask. +const PREDICTION_THRESHOLD: f32 = 0.5; + +/// Creates a mask of valid insertion positions by adding all pixels in the drop mask and subsequently subtracting those in the crystal masks. +fn insertion_mask( + drop_mask: ArrayView2, + crystal_masks: Vec>, +) -> Array2 { + let mut mask = drop_mask.mapv(|prediction| prediction > PREDICTION_THRESHOLD); + crystal_masks.into_iter().for_each(|crystal_mask| { + mask.zip_mut_with(&crystal_mask, |valid, prediction| { + *valid &= *prediction < PREDICTION_THRESHOLD + }) + }); + mask +} + +/// Converts an [`Array2`] into an [`Mat`] of type [`CV_8U`] with the same dimensions. +fn ndarray_mask_into_opencv_mat(mask: Array2) -> Mat { + Mat::from_exact_iter( + mask.mapv(|pixel| if pixel { std::u8::MAX } else { 0 }) + .into_iter(), + ) + .unwrap() + .reshape_nd( + 1, + &mask + .shape() + .iter() + .map(|&dim| dim as i32) + .collect::>(), + ) + .unwrap() +} + +/// Performs a distance transform to find the point in the mask which is furthest from any invalid region. +/// +/// Returns an [`anyhow::Error`] if no valid insertion point was found. +fn optimal_insert_position(insertion_mask: Mat) -> Result { + let mut distances = Mat::default(); + distance_transform(&insertion_mask, &mut distances, DIST_L1, DIST_MASK_3, CV_8U).unwrap(); + let (furthest_point, _) = distances + .iter::() + .unwrap() + .max_by(|(_, a), (_, b)| a.cmp(b)) + .context("No valid insertion points")?; + Ok(Point { + x: furthest_point.x as usize, + y: furthest_point.y as usize, + }) +} + +/// Converts an [`ArrayView) -> BBox { + BBox { + left: bbox[0], + top: bbox[1], + right: bbox[2], + bottom: bbox[3], + } +} + +/// Finds the first instance which is labelled as a drop. +/// +/// Returns an [`anyhow::Error`] if no drop instances were found. +fn find_drop_instance<'a>( + labels: &Labels, + bboxes: &BBoxes, + masks: &'a Masks, +) -> Result<(BBox, ArrayView2<'a, f32>), anyhow::Error> { + izip!(labels, bboxes.outer_iter(), masks.outer_iter()) + .find_map(|(label, bbox, mask)| (*label == 1).then_some((bbox_from_array(bbox), mask))) + .context("No drop instances in prediction") +} + +/// Finds all instances which are labelled as crystals. +fn find_crystal_instances<'a>( + labels: &Labels, + bboxes: &BBoxes, + masks: &'a Masks, +) -> Vec<(BBox, ArrayView2<'a, f32>)> { + izip!(labels, bboxes.outer_iter(), masks.outer_iter()) + .filter_map(|(label, bbox, mask)| (*label == 2).then_some((bbox_from_array(bbox), mask))) + .collect() +} + +/// Takes the results of inference on an image and uses it to produce useful regional data and an optimal insertion point. +/// +/// Returns an [`anyhow::Error`] if no drop instances could be found or if no valid insertion point was found. +fn postprocess_inference( + bboxes: BBoxes, + labels: Labels, + masks: Masks, +) -> Result { + let (drop, drop_mask) = find_drop_instance(&labels, &bboxes, &masks)?; + let (crystals, crystal_masks) = find_crystal_instances(&labels, &bboxes, &masks) + .into_iter() + .unzip(); + let insertion_mask = ndarray_mask_into_opencv_mat(insertion_mask(drop_mask, crystal_masks)); + let insertion_point = optimal_insert_position(insertion_mask)?; + Ok(Contents { + drop, + crystals, + insertion_point, + }) +} + +/// Takes the results of inference on an image and uses it to produce useful regional data and an optimal insertion point. +/// +/// The extracted [`Contents`] are sent over a [`tokio::sync::mpsc::unbounded_channel`] if sucessful. +/// An [`anyhow::Error`] is sent if no drop instances were found or if no valid insertion point was found. +pub async fn inference_postprocessing( + bboxes: BBoxes, + labels: Labels, + masks: Masks, + job: Job, + contents_tx: UnboundedSender<(Contents, Job)>, + error_tx: UnboundedSender<(anyhow::Error, Job)>, +) { + println!("Postprocessing: {job:?}"); + match postprocess_inference(bboxes, labels, masks) { + Ok(contents) => contents_tx.send((contents, job)).unwrap(), + Err(err) => error_tx.send((err, job)).unwrap(), + } +} + +#[cfg(test)] +mod tests { + use super::optimal_insert_position; + use opencv::{ + core::{Point_, Scalar, CV_8UC1}, + imgproc::{circle, LINE_8}, + prelude::Mat, + }; + + #[test] + fn optimal_insert_found() { + let mut test_image = Mat::new_nd_with_default( + &[1024, 1224], + CV_8UC1, + Scalar::new(0_f64, 0_f64, 0_f64, std::u8::MAX as f64), + ) + .unwrap(); + circle( + &mut test_image, + Point_::new(256, 512), + 128, + Scalar::new( + std::u8::MAX as f64, + std::u8::MAX as f64, + std::u8::MAX as f64, + std::u8::MAX as f64, + ), + -1, + LINE_8, + 0, + ) + .unwrap(); + + let position = optimal_insert_position(test_image).unwrap(); + + assert_eq!(256, position.x); + assert_eq!(512, position.y); + } +} diff --git a/chimp_chomp/src/well_centering.rs b/chimp_chomp/src/well_centering.rs new file mode 100644 index 00000000..64424e75 --- /dev/null +++ b/chimp_chomp/src/well_centering.rs @@ -0,0 +1,118 @@ +use crate::image_loading::WellImage; +use anyhow::Context; +use chimp_protocol::{Circle, Job, Point}; +use opencv::{ + core::{Vec4f, Vector}, + imgproc::{hough_circles, HOUGH_GRADIENT}, + prelude::MatTraitConst, +}; +use std::ops::Deref; +use tokio::sync::mpsc::UnboundedSender; + +/// Uses a canny edge detector and a hough circle transform to localise a [`Circle`] of high contrast in the image. +/// +/// The circle is assumed to have a radius in [⅜ l, ½ l), where `l` denotes the shortest edge lenth of the image. +/// The circle with the most counts is selected. +/// +/// Returns an [`anyhow::Error`] if no circles were found. +fn find_well_location(image: WellImage) -> Result { + let min_side = *image.deref().mat_size().iter().min().unwrap(); + let mut circles = Vector::::new(); + hough_circles( + &*image, + &mut circles, + HOUGH_GRADIENT, + 4.0, + 1.0, + 100.0, + 100.0, + min_side * 3 / 8, + min_side / 2, + ) + .unwrap(); + let well_location = circles + .into_iter() + .max_by(|&a, &b| a[3].total_cmp(&b[3])) + .context("No circles found in image")?; + Ok(Circle { + center: Point { + x: well_location[0] as usize, + y: well_location[1] as usize, + }, + radius: well_location[2], + }) +} + +/// Takes a grayscale image of the well and finds the center and radius. +/// +/// The extracted [`Circle`] is sent over a [`tokio::sync::mpsc::unbounded_channel`] if sucessful. +/// An [`anyhow::Error`] is sent if no circles were found. +pub async fn well_centering( + image: WellImage, + job: Job, + well_location_tx: UnboundedSender<(Circle, Job)>, + error_tx: UnboundedSender<(anyhow::Error, Job)>, +) { + println!("Finding Well Center for {job:?}"); + match find_well_location(image) { + Ok(well_center) => well_location_tx.send((well_center, job)).unwrap(), + Err(err) => error_tx.send((err, job)).unwrap(), + } +} + +#[cfg(test)] +mod tests { + use crate::{image_loading::WellImage, well_centering::find_well_location}; + use approx::assert_relative_eq; + use opencv::{ + core::{Mat, Point_, Scalar, CV_8UC1}, + imgproc::{circle, LINE_8}, + }; + + #[test] + fn well_center_found() { + const CENTER_X: usize = 654; + const CENTER_Y: usize = 321; + const RADIUS: f32 = 480.0; + const THICKNESS: i32 = 196; + + let mut test_image = Mat::new_nd_with_default( + &[1024, 1224], + CV_8UC1, + Scalar::new( + std::u8::MAX as f64, + std::u8::MAX as f64, + std::u8::MAX as f64, + std::u8::MAX as f64, + ), + ) + .unwrap(); + circle( + &mut test_image, + Point_ { + x: CENTER_X as i32, + y: CENTER_Y as i32, + }, + RADIUS as i32 + THICKNESS / 2, + Scalar::new(0_f64, 0_f64, 0_f64, std::u8::MAX as f64), + THICKNESS, + LINE_8, + 0, + ) + .unwrap(); + + let location = find_well_location(WellImage(test_image)).unwrap(); + + assert_relative_eq!( + CENTER_X as f64, + location.center.x as f64, + max_relative = 8.0 + ); + assert_relative_eq!( + CENTER_Y as f64, + location.center.y as f64, + max_relative = 8.0 + ); + assert_relative_eq!(RADIUS, location.radius, max_relative = 8.0) + } +} diff --git a/chimp_protocol/Cargo.toml b/chimp_protocol/Cargo.toml new file mode 100644 index 00000000..fa03723b --- /dev/null +++ b/chimp_protocol/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "chimp_protocol" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.166" } +serde_json = "1.0.100" +uuid = { workspace = true, features = ["serde"] } diff --git a/chimp_protocol/README.md b/chimp_protocol/README.md new file mode 100644 index 00000000..c2237153 --- /dev/null +++ b/chimp_protocol/README.md @@ -0,0 +1,3 @@ +# CHiMP Protocol + +This library defines a number data structures common to CHiMP - each of which implement (de)serialization to / from JSON. diff --git a/chimp_protocol/src/lib.rs b/chimp_protocol/src/lib.rs new file mode 100644 index 00000000..07d3e83e --- /dev/null +++ b/chimp_protocol/src/lib.rs @@ -0,0 +1,98 @@ +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![doc=include_str!("../README.md")] + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use uuid::Uuid; + +/// A CHiMP job definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Job { + /// A unique identifier for the job, to be returned in the [`Response`]. + pub id: Uuid, + /// The path of a file containing the image to perform inference on. + pub file: PathBuf, + /// The channel to send predictions to. + pub predictions_channel: String, +} + +impl Job { + /// Deserialize an instance [`Request`] from bytes of JSON text. + pub fn from_slice(v: &[u8]) -> Result { + serde_json::from_slice(v) + } + + /// Serialize the [`Request`] as a JSON byte vector + pub fn to_vec(&self) -> Result, serde_json::Error> { + serde_json::to_vec(&self) + } +} + +/// A set of predictions which apply to a single image. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Response { + /// The image was processed successfully, producing the contained predictions. + Success { + /// The unique identifier of the requesting [`Job`]. + job_id: Uuid, + /// The proposed point for solvent insertion. + insertion_point: Point, + /// The location of the well centroid and radius. + well_location: Circle, + /// A bounding box emcompasing the solvent. + drop: BBox, + /// A set of bounding boxes, each emcompasing a crystal. + crystals: Vec, + }, + /// Image processing failed, with the contained error. + Failure { + /// The unique identifier of the requesting [`Job`]. + job_id: Uuid, + /// A description of the error encountered. + error: String, + }, +} + +impl Response { + /// Deserialize an instance [`Predictions`] from bytes of JSON text. + pub fn from_slice(v: &[u8]) -> Result { + serde_json::from_slice(v) + } + + /// Serialize the [`Predictions`] as a JSON byte vector + pub fn to_vec(&self) -> Result, serde_json::Error> { + serde_json::to_vec(&self) + } +} + +/// A point in 2D space. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Point { + /// The position of the point in the X axis. + pub x: usize, + /// The position of the point in the Y axis. + pub y: usize, +} + +/// A circle, defined by the center point and radius. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Circle { + /// The position of the circles center. + pub center: Point, + /// The radius of the circle. + pub radius: f32, +} + +/// A bounding box which encompasing a region. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BBox { + /// The position of the upper bound in the Y axis. + pub top: f32, + /// The position of the lower bound in the Y axis. + pub bottom: f32, + /// The position of the upper bound in the X axis. + pub right: f32, + /// The position of the lower bound in the X axis. + pub left: f32, +}