diff --git a/annotation_utilities/annotation_utilities/annotation_tools.py b/annotation_utilities/annotation_utilities/annotation_tools.py index a7037a4..ea79502 100644 --- a/annotation_utilities/annotation_utilities/annotation_tools.py +++ b/annotation_utilities/annotation_utilities/annotation_tools.py @@ -1,6 +1,5 @@ from shapely.geometry import Point, Polygon import numpy as np -import matplotlib.colors as mcolors # Note that this function should reverse x and y. It is used by point_count_worker, which mixes up x and y for the polygons as well, so it works consistently. @@ -298,6 +297,11 @@ def get_layers(GirderClient, datasetId): def process_and_merge_channels(images, layers, mode='lighten'): + # Imported lazily: matplotlib is heavy (~50ms) and this is the only function + # in annotation_tools that uses it, yet annotation_tools is imported at module + # load by nearly every worker. See todo/worker-startup-latency.md. + import matplotlib.colors as mcolors + layers = sorted(layers, key=lambda x: x['channel']) processed_channels = [] diff --git a/build_all_property_and_annotation_workers.sh b/build_all_property_and_annotation_workers.sh index 876f7ca..ce5e4f6 100755 --- a/build_all_property_and_annotation_workers.sh +++ b/build_all_property_and_annotation_workers.sh @@ -37,7 +37,7 @@ docker build -f ./workers/annotations/connect_timelapse/$DOCKERFILE -t annotatio # docker build -f ./workers/annotations/connect_timelapse/Dockerfile_M1 -t annotations/connect_time_lapse:latest ./workers/annotations/connect_timelapse/ echo "Building annotation worker: laplacian_of_gaussian" -docker build -f ./workers/annotations/laplacian_of_gaussian/$DOCKERFILE -t annotations/laplacian_of_gaussian:latest . $NO_CACHE +docker build -f ./workers/annotations/laplacian_of_gaussian/Dockerfile -t annotations/laplacian_of_gaussian:latest . $NO_CACHE # docker build -f ./workers/annotations/laplacian_of_gaussian/Dockerfile_M1 -t annotations/laplacian_of_gaussian:latest ./workers/annotations/laplacian_of_gaussian/ @@ -69,7 +69,7 @@ docker build -f ./workers/properties/blobs/blob_annulus_intensity_percentile_wor # docker build -f ./workers/properties/blobs/blob_annulus_intensity_percentile_worker/Dockerfile_M1 -t properties/blob_annulus_intensity_percentile:latest ./workers/properties/blobs/blob_annulus_intensity_percentile_worker/ echo "Building property worker: blob_colony_two_color_intensity_worker" -docker build -f ./workers/properties/blobs/blob_colony_two_color_intensity_worker/$DOCKERFILE -t properties/blob_colony_two_color_intensity:latest . $NO_CACHE +docker build -f ./workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile -t properties/blob_colony_two_color_intensity:latest . $NO_CACHE # docker build -f ./workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile_M1 -t properties/blob_colony_two_color_intensity:latest ./workers/properties/blobs/blob_colony_two_color_intensity_worker/ echo "Building property worker: blob_point_count_worker" @@ -102,7 +102,7 @@ docker build -f ./workers/properties/points/point_to_nearest_blob_distance/$DOCK # docker build -f ./workers/properties/points/point_to_nearest_blob_distance/Dockerfile_M1 -t properties/point_to_nearest_blob_distance:latest ./workers/properties/points/point_to_nearest_blob_distance/ echo "Building property worker: line_scan_worker" -docker build -f ./workers/properties/lines/line_scan_worker/$DOCKERFILE -t properties/line_scan_worker:latest . $NO_CACHE +docker build -f ./workers/properties/lines/line_scan_worker/Dockerfile -t properties/line_scan_worker:latest . $NO_CACHE # docker build -f ./workers/properties/lines/line_scan_worker/Dockerfile_M1 -t properties/line_scan_worker:latest ./workers/properties/lines/line_scan_worker/ # Image processing workers diff --git a/docker-compose.yml b/docker-compose.yml index 47e0ce4..ba42c16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,7 @@ services: laplacian_of_gaussian: build: context: . - dockerfile: ./workers/annotations/laplacian_of_gaussian/${DOCKERFILE:-Dockerfile} + dockerfile: ./workers/annotations/laplacian_of_gaussian/Dockerfile image: annotations/laplacian_of_gaussian:latest profiles: ["worker", "annotations"] @@ -92,7 +92,7 @@ services: blob_colony_two_color_intensity: build: context: . - dockerfile: ./workers/properties/blobs/blob_colony_two_color_intensity_worker/${DOCKERFILE:-Dockerfile} + dockerfile: ./workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile image: properties/blob_colony_two_color_intensity:latest profiles: ["worker", "properties"] @@ -156,7 +156,7 @@ services: line_scan_worker: build: context: . - dockerfile: ./workers/properties/lines/line_scan_worker/${DOCKERFILE:-Dockerfile} + dockerfile: ./workers/properties/lines/line_scan_worker/Dockerfile image: properties/line_scan_worker:latest profiles: ["worker", "properties"] diff --git a/todo/TODO_REGISTRY.md b/todo/TODO_REGISTRY.md index 7454a27..eddd68e 100644 --- a/todo/TODO_REGISTRY.md +++ b/todo/TODO_REGISTRY.md @@ -13,6 +13,7 @@ Master index of deferred work, technical debt, and future improvements for the I | ID | Title | Status | Priority | File | Related PR | |----|-------|--------|----------|------|------------| | TODO-001 | ML worker build optimization (mamba + shared base images) | Deferred | Medium | [ml-worker-build-optimization.md](ml-worker-build-optimization.md) | [#132](https://github.com/arjunrajlaboratory/ImageAnalysisProject/pull/132) | +| TODO-002 | Worker startup latency audit & slimming plan (drop `conda run`) | In progress (#1–#3 done & verified; GPU workers static-validated, need build-host validation) | High | [worker-startup-latency.md](worker-startup-latency.md) | — | ## Completed TODOs diff --git a/todo/worker-startup-latency.md b/todo/worker-startup-latency.md new file mode 100644 index 0000000..f73e8c2 --- /dev/null +++ b/todo/worker-startup-latency.md @@ -0,0 +1,503 @@ +# TODO-002: Worker Startup Latency Audit & Slimming Plan + +**Status:** In progress +**Priority:** High +**Related PR:** [#148](https://github.com/arjunrajlaboratory/ImageAnalysisProject/pull/148) + +## Summary + +NimbusImage runs each worker as a fresh container that is torn down per job: + +```bash +docker run --rm ... --apiUrl --token --request --parameters --datasetId +``` + +Images are already **local** (NOT pulled at runtime), so registry pull is not the +cost. The goal of this audit is to reduce the ~1+ second of **in-container +startup** that elapses before real work begins. + +Measurements were taken on native **arm64** local builds. Production is **amd64**, +but the relative costs hold. + +**Headline:** The `conda run` wrapper in the worker `ENTRYPOINT` is **~70% of +startup cost** and is identical across all 76 worker images. Imports are a +distant second. Base-image / R / size issues do **NOT** affect startup (images +are local, never pulled) — they are disk/build-only concerns. + +## Measurements + +Light worker `properties/blob_intensity` and ML worker `annotations/deconwolf`: + +| What | Time | +|------|------| +| Container + DIRECT env-python boot, no imports | ~0.20 s (floor) | +| `conda run … python -c "pass"` (no imports) | ~1.0–1.2 s → **+0.8–1.0 s pure wrapper overhead** | +| Full entrypoint, all top imports, no network (`--help`) | ~1.2–1.3 s (imports add only ~0.2–0.3 s) | +| micromamba `_entrypoint.sh … python -c pass` (test base) | ~0.22 s (activation with ~0 overhead) | + +`deconwolf` (image-processing base, imports `large_image`/`tifffile`) measured +identically: `conda run` ~1.0–1.2 s, imports +0.2–0.3 s. + +### Why `conda run` is slow + +`conda run` launches a **second** Python process — the `conda` CLI is itself a +Python program that parses args, sources activation, then execs the target +interpreter. That is two interpreter starts plus conda's own imports, per job. + +## Section 1 — Entry points + +There are 59 `workers/**/entrypoint.py` files, all of identical shape: + +1. All top-level imports run. +2. Then `argparse`. +3. Then a `match args.request` dispatch at the bottom (`compute` vs `interface`). + +Each is invoked as: + +```bash +conda run … python3 /entrypoint.py --apiUrl … --request interface|compute … +``` + +**Consequence:** the lightweight `interface` request (just builds a UI dict and +POSTs it; the interactive, user-facing path) pays the **full** top-level import +bill, including heavy ML libraries it never uses. + +## Section 2 — Import cost + +### Every-run, unavoidable (~200–300 ms total) + +`numpy`, `shapely`, `annotation_client.*`, `annotation_utilities.*`, `skimage`. + +From `-X importtime` on the light worker: + +| Module / child | Cumulative | +|----------------|------------| +| `annotation_client.tiles` → `tifffile` | ~66 ms | +| `annotation_utilities.annotation_tools` → `matplotlib` | ~53 ms | +| `numpy` | ~57 ms | +| `girder_client` / `requests` | ~30 ms | + +### Cross-cutting lazy win + +`annotation_utilities/annotation_tools.py:3`: + +```python +import matplotlib.colors as mcolors +``` + +is used exactly **once**, at line 324 (`mcolors.to_rgb(layer['color'])`). +`annotation_tools` is imported by ~40 workers, so nearly every worker pays +~53 ms for a single color conversion. + +### Heavy ML imports at module top (only needed for `compute`) + +These should be deferred into `compute()`: + +| Worker | Location | Imports | +|--------|----------|---------| +| cellposesam | `cellposesam/entrypoint.py:12-14` | `deeptile` → `cellpose_segmentation`, `cellpose` + `torch`; interface at `:26` only lists models | +| stardist | `stardist/entrypoint.py:13` | `from stardist.models import StarDist2D` (TensorFlow) | +| sam2_propagate + all `sam2_*` / `sam_*` | `sam2_propagate/entrypoint.py:21-24` | `torch`, `sam2.*` | +| deepcell | `deepcell/entrypoint.py:11-12` | `deeptile` → `deepcell_mesmer_segmentation` | +| piscis predict / train | — | `torch`, `piscis` | +| cellpose_train | — | `cellpose` | +| blob_random_forest_classifier | — | `sklearn`, `pandas` (compute only) | + +These import in **seconds**; deferring them makes the `interface` request +near-instant with no cost to `compute`. + +## Section 3 — How to measure (reproducible) + +```bash +PY=/root/miniconda3/envs/worker/bin/python3.12 # miniconda on arm64, miniforge on amd64; python3.11 for some workers +IMG=properties/blob_intensity:latest +``` + +**(a) `conda run` vs direct python:** + +```bash +time docker run --rm --entrypoint conda $IMG run --no-capture-output -n worker python3 -c "pass" +time docker run --rm --entrypoint $PY $IMG -c "pass" +``` + +**(b) full app startup, imports + argparse, NO network (dry/no-op mode):** + +```bash +time docker run --rm $IMG --help +``` + +**(c) import breakdown:** + +```bash +docker run --rm --entrypoint $PY $IMG -X importtime -c \ + "import annotation_client.workers, annotation_client.tiles, annotation_utilities.annotation_tools, numpy, skimage.draw" \ + 2> importtime.log +sort -t'|' -k2 -n -r importtime.log | head -25 +``` + +**Reading `-X importtime`:** columns are `self | cumulative | module` in +microseconds; indentation = nesting. Sort by column 2 (cumulative) and read the +least-indented rows = top-level modules including their children. A +multi-second low-indent row (e.g. `torch`) is the answer. Here the largest is +`annotation_client.workers` at ~127 ms cumulative — proof that imports aren't +the bottleneck. + +`--help` is the no-op/dry mode: it exercises the interpreter and all imports +without `--apiUrl`/`--token`, isolating startup from network. + +## Section 4 — Dockerfile / base image (size-only, NOT startup) + +`workers/base_docker_images/Dockerfile.worker_base`: `FROM ubuntu:jammy`; full +Miniconda/Miniforge (env = 2.4 GB); workers layer `COPY entrypoint.py` + a +`conda run` `ENTRYPOINT`. + +- **`r-base`** installed (lines 13 and 18) and **completely unused** (no `.R` + files, no `rpy2`, no `Rscript` anywhere). `/usr/lib/R` = 63 MB + apt deps. + Dead weight from a template. +- **`build-essential`** (`:10`), **`git`** (`:19`), `libpython3-dev`, dev headers + left in the final image — no multi-stage prune (the `FROM base AS build` stage + is the shipped image). +- Full conda, not slim. + +**Framing:** images are local and not pulled at runtime, so **none** of these +affect startup latency — these are disk/build/registry wins only, and lower +priority for the stated goal. + +## Section 5 — Shared startup boilerplate + +Every worker builds a Girder client (`UPennContrastWorkerClient` / +`…PreviewClient`); `__init__` just stores `apiUrl`/`token` (no network — see +`annotation_client/annotations.py`), so construction is cheap. The Girder API +handshake is real, but it is network RTT to the server, **not** container +startup — slimming won't touch it. Profile it separately (e.g. is `interface` +making redundant round-trips?). `worker_client/worker_client/worker_client.py:1-15` +imports are all light. + +## Section 6 — Ranked opportunities + +### A. In-container app-startup wins (these move the needle) + +| # | Opportunity | Files | Payoff | Effort | Risk / caveat | +|---|-------------|-------|--------|--------|---------------| +| **1 ⭐** | **Drop `conda run` from `ENTRYPOINT`.** Bake activation into the image: `ENV PATH=/…/envs/worker/bin:$PATH` plus the activate.d-exported vars, then `ENTRYPOINT ["python","/entrypoint.py"]`; or migrate conda bases to the micromamba pattern the test base already uses. | All 76 Dockerfiles (cleanest via the 3 base images) | **~0.8–1.0 s off EVERY job** (compute AND interface), ~70% of light-worker startup | Low–Med (mechanical, touches all workers) | **MUST preserve conda's activation env vars.** Confirmed empirically: direct python loses `GDAL_DATA`, `PROJ_DATA`, `GDAL_DRIVER_PATH` (set by `gdal-activate.sh`/`proj4-activate.sh`), which GDAL/rasterio/large_image need at runtime (CRS transforms, format drivers). A naive `--entrypoint python` switch would **silently break** image-processing/rasterio workers. Bake those vars via `ENV`, or use micromamba `_entrypoint.sh` (~0.22 s, ~0 overhead, runs activation). | +| 2 | Defer heavy ML imports into `compute()`. | cellposesam:12-14, stardist:13, sam2_*/sam_*:21-24, deepcell:11-12, piscis, cellpose_train | `interface` ~instant for ML workers (saves **seconds** of torch/TF/cellpose import on the interactive path), no cost to compute | Low per worker | Verify `interface()` truly doesn't use the lib (confirmed for those checked); add a comment so it isn't "tidied" back up. | +| 3 | Lazy/remove matplotlib in shared module. | `annotation_tools.py:3` → move into fn at `:324`, or replace `mcolors.to_rgb` with a hex/named-color parser | ~50 ms off ~40 workers | Low | None if moved as-is; replacing `to_rgb` needs a correct parser. | +| 4 | Trim other always-on imports if cheap (confirm `tifffile`/`girder_client` truly needed at top). | — | ~50–100 ms | Low | Secondary. | + +### B. Image-size wins (do NOT affect startup — disk/build only) + +| # | Opportunity | Files | Payoff | Effort | +|---|-------------|-------|--------|--------| +| 5 | Remove `r-base` from base apt installs (unused). | `Dockerfile.worker_base:13,18`; `Dockerfile.image_processing_worker_base:13,18` | ~63 MB + r deps | Low | +| 6 | Multi-stage build: drop `build-essential`/`git`/dev headers from the final layer. | Both conda base Dockerfiles | Hundreds of MB | Med | +| 7 | `conda clean -afy` / slimmer env after `env create`. | Base Dockerfiles | Trims the 2.4 GB env | Low | + +## Single highest-leverage change + +**#1 — replace `conda run` in the `ENTRYPOINT` with a pre-activated +direct-`python` entrypoint** (preserving GDAL/PROJ activation vars). + +It is the only change that helps **every worker on every invocation**; it is +~70% of measured light-worker startup; and the micromamba base proves activation +can cost ~0 ms. Validate a rasterio/large_image worker end-to-end before rolling +the base-image change out to all 76 images. + +## Status / Next steps + +- Items **#1 (drop `conda run`)**, **#2 (defer ML imports)**, and **#3 (lazy + matplotlib)** are **implemented and verified** (see the Implementation Log). +- The entrypoint change (#1) has been **rolled out to all production workers** — + the 19 shared-base workers (via the base images) plus the 28 GPU / + self-contained production Dockerfiles (see the 2026-06-27 *continued* log). +- Image-size wins **#5 (drop r-base)** and **#7 (conda clean)** are **done and + verified**. **#6 (multi-stage prune)** and **#4 (trim other always-on imports)** + remain open. +- The GPU-worker import changes (#2) were **build-host validated 2026-06-28**: + all 13 GPU workers build + smoke-clean on a g4dn/driver-580 host. Three + piscis-specific issues surfaced and were fixed there (a `run_worker.sh` + build-context error, a vestigial jax/flax dependency, and a torch import on the + `interface` path) — see the 2026-06-28 log. Still untested: a real end-to-end + `compute` run against live data. + +## Implementation Log — 2026-06-27 + +Items #1, #2, and #3 from the ranked opportunities above were implemented and +verified. The audit content above (measurements, tables, rankings) is unchanged +and remains the source of truth for the *why*; this log records the *what was +done*. + +### The fix mechanism (item #1) + +A new shared script **`workers/base_docker_images/run_worker.sh`** replaces the +per-worker `conda run --no-capture-output -n worker python /entrypoint.py` +`ENTRYPOINT`. It activates the conda env **in-process** (sets +`CONDA_PREFIX`/`PATH`, sources the env's `activate.d` hooks so `GDAL_DATA`, +`PROJ_DATA`, and `GDAL_DRIVER_PATH` survive) and then `exec`s the env python — +avoiding conda's second Python process. It auto-detects miniforge (amd64) vs +miniconda (arm64). + +This directly addresses the headline finding (the `conda run` wrapper is ~70% of +startup) while preserving the GDAL/PROJ activation vars flagged as the key risk +in opportunity #1. + +### 1. Baked into the 2 conda base images — DONE & VERIFIED + +- `Dockerfile.worker_base` and `Dockerfile.image_processing_worker_base` now + `COPY run_worker.sh` and set + `ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"]`. +- The **19 production workers** built `FROM` these bases had their per-worker + `conda run` `ENTRYPOINT` removed (replaced with an inheritance comment) so they + inherit the fast one. +- `worker_client` was added to `worker-base` (mirroring image-processing-base) so + CPU workers can build `FROM` it cleanly. + +**Measured results** (arm64, full startup to ready via `--help`, median of 5): + +| Worker | Before | After | +|--------|--------|-------| +| blob_intensity | 1.21 s | 0.41 s | +| crop | 1.22 s | 0.44 s | +| blob_metrics | ~1.2 s | 0.40 s | +| registration | ~1.2 s | 0.51 s | + +~0.8 s saved per job (~65–70%), matching the audit's predicted ~0.8–1.0 s. Verified +GDAL/PROJ env vars are **identical** to the conda-run reference; `large_image` / +`rasterio` import fine. Tests pass: blob_metrics 9, registration 20, crop 14, +blob_intensity 9. + +### 1b. Refactored 3 inline production workers onto worker-base — DONE & VERIFIED + +`laplacian_of_gaussian`, `line_scan_worker`, and +`blob_colony_two_color_intensity_worker` were self-contained +`FROM ubuntu:jammy` builds. They are now `FROM nimbusimage/worker-base:latest` +(so they inherit the fast entrypoint), dropping: + +- their duplicated base build, +- the stale `Kitware/UPennContrast` client clone (the base uses the current + `arjunrajlaboratory/NimbusImage`), +- the hardcoded x86_64 miniforge. + +Made arch-agnostic, so their obsolete `Dockerfile_M1` files were **deleted** and +their docker-compose entries pinned to the literal `Dockerfile`. +line_scan_worker's `__main__` was aligned to the canonical `match args.request` +dispatch pattern. Verified: all three inherit `run_worker.sh`, are fast +(0.39 / 0.54 / 0.38 s), and line_scan tests (7) pass. + +**Not refactored** (left on their current path, by decision): +`blob_random_forest_classifier` (needs sklearn/mahotas) and `ai_analysis` +(py3.9 + anthropic). + +### 3. matplotlib lazy import (item #3) — DONE & VERIFIED + +`annotation_utilities/annotation_tools.py` — moved +`import matplotlib.colors as mcolors` from the module top into the only function +that uses it (`process_and_merge_channels`). Verified matplotlib no longer loads +when `annotation_tools` is imported (~50 ms saved across ~all workers, as +predicted in opportunity #3). + +### 2. Deferred heavy ML imports into compute() (item #2) — DONE, STATIC-VALIDATED ONLY + +Heavy library imports were moved from module top into `compute()` (and the +helper functions that use them) for all **13 GPU production workers**, so the +`interface` request and container startup no longer pay multi-second ML imports: + +- cellpose, cellpose_train, cellposesam, condensatenet, deepcell, stardist +- sam2_automatic_mask_generator, sam2_fewshot_segmentation, + sam_fewshot_segmentation, sam2_propagate, sam2_refine, sam2_video +- piscis_predict, piscis_train + +Libraries deferred: torch / tensorflow / cellpose / deeptile / stardist / sam2 / +segment_anything / deepcell / piscis. Two transitive cases were also handled: + +- condensatenet's `from condensatenet import CondensateNetPipeline` moved into + its segmentation function. +- sam_fewshot's module-level `_patch_torchvision_nms()` call moved into + `compute()`. + +A few genuinely-unused heavy imports were dropped during this pass (e.g. +sam2_propagate's `SAM2AutomaticMaskGenerator`, sam2_video's `build_sam2`). + +**Verified (static):** all 13 pass `python3 -m py_compile`; independent grep +confirms NO module-level heavy imports remain **except** +`from piscis.paths import MODELS_DIR` in the two piscis workers (used by +`interface()` to list models). **[Update 2026-06-28: that exception turned out to +defeat the speedup for piscis — `from piscis.paths import …` runs +`piscis/__init__.py` → `from piscis.core import Piscis` → `import torch`, so the +`interface` request still paid the full ~4 s torch import. Fixed; see the +2026-06-28 log.]** + +> **CAVEAT — GPU workers require build-host validation before deploy. [RESOLVED +> 2026-06-28 — see the 2026-06-28 log.]** The item-#2 changes were +> static-validated only (py_compile + usage analysis); GPU images cannot be built +> locally (no CUDA/torch). All 13 GPU workers have since been built on an amd64 +> g4dn host (driver 580) and smoke-tested — imports resolve at runtime and the +> workers start on GPU. The piscis `MODELS_DIR` question is answered: **yes**, it +> triggered piscis's `__init__` → `import torch` (~4 s on every `interface`); now +> fixed with a torch-free `MODELS_DIR`. Still untested: a full `compute` run +> against live image data. + +## Implementation Log — 2026-06-27 (continued): rollout + image-size wins + +### 4. Entrypoint rollout to all remaining production workers (task a) — DONE + +The base-image bake (above) covered only workers built `FROM` the 2 shared +bases. The **28 remaining production Dockerfiles** — which build their own conda +env (GPU workers on `nvidia/cuda`, plus the inline CPU workers) — were converted +to `run_worker.sh` directly (`COPY` + `chmod` + the `run_worker.sh` `ENTRYPOINT`, +replacing `conda run`), including their `_M1` variants: + +- 13 GPU workers: cellpose, cellpose_train, cellposesam, condensatenet, deepcell, + stardist, sam2_automatic_mask_generator, sam2_fewshot_segmentation, + sam_fewshot_segmentation, sam2_propagate, sam2_refine, sam2_video, + piscis_predict, piscis_train +- deconwolf (compose, `nvidia/cuda`) +- ai_analysis, blob_random_forest_classifier (inline CPU) + +`run_worker.sh` auto-detects the self-built env (`/root/miniforge3/envs/worker` +on amd64, `/root/miniconda3/envs/worker` on arm64), so the same script works for +these workers unchanged. + +Also: **sam_automatic_mask_generator** (flagged in review — it had been missed in +the item-#2 pass) had its module-level `torch` / `segment_anything` deferred into +its `segment_image` helper, and its entrypoint converted. + +**Verified:** all 28 Dockerfiles statically checked (COPY present, `run_worker.sh` +ENTRYPOINT, no `conda run` remaining). Runtime-validated on +`blob_random_forest_classifier` (a self-built-env worker — env auto-detected, +`--help` exits 0 at ~0.8 s, 4 tests pass). **GPU workers still require build-host +(amd64) validation** per the caveat above. + +Deliberately **left on `conda run`**: deprecated workers (not in the production +manifest) and the `$BASE_IMAGE`-ARG workers (unknown env layout). + +### 5. Image-size wins #5 + #7 (task b) — DONE & VERIFIED + +- Removed the unused **`r-base`** from both base images' apt installs. +- Added **`conda clean --all --yes`** after env setup in both bases. + +| Base image | Before | After | +|------------|--------|-------| +| `nimbusimage/worker-base` | 5.18 GB | 4.74 GB | +| `nimbusimage/image-processing-base` | 9.65 GB | 9.09 GB | + +Verified `Rscript` is gone from both bases and that workers on each base still +build and import (`blob_metrics`; `crop` with GDAL vars intact). These are +disk/build wins only — they do not affect startup latency. + +### Review fixes + +- The new files (`run_worker.sh`, this doc) are now tracked. +- `build_all_property_and_annotation_workers.sh` points the 3 refactored workers + at the literal `Dockerfile` (their `$DOCKERFILE`→`_M1` files were deleted). +- piscis predict/train entrypoints normalized CRLF→LF (`git diff --check` clean). + +## Implementation Log — 2026-06-28: interface-path import deferral + unused-import sweep + +Grounded in fresh measurements: stdlib and `skimage` (lazy submodule loading) are +~0; the real weight is `scipy.spatial` (~0.28s), `geopandas` (~0.25s), `pandas` +(~0.20s), `numpy` (~0.10s). Crucial rule confirmed empirically: **a dead/unused +import only costs startup if its library isn't already loaded by a needed +module.** `annotation_utilities.annotation_tools` (imported by ~all workers) always +pulls `numpy`+`shapely`, so cutting a worker's direct `import numpy` saves ~0 +(verified: removing all 11 of crop's dead imports → 0.44s→0.44s). + +### Deferred heavy compute-only libs into compute()/helpers (interface path faster) +17 production entrypoints: libs used only in compute/helpers (never in +`interface`/`preview`) moved into those functions, each with a `# Lazy import: …` +comment. Libraries deferred: pandas, geopandas, scipy, sklearn, mahotas, +large_image, rasterio. + +- connect_to_nearest / connect_sequential / connect_timelapse — pandas, geopandas, scipy +- blob_overlap_worker (geopandas); blob_random_forest_classifier (pandas, sklearn, mahotas); + children_count_worker (pandas); line_scan_worker (pandas, scipy) +- crop / gaussian_blur / h_and_e_deconvolution / histogram_matching / rolling_ball / + deconwolf — large_image +- stardist / piscis_train — rasterio (STATIC-VALIDATED ONLY; GPU build host needed) + +Measured interface-path proxy (`--help`, median of 3): **blob_random_forest 0.82→0.32s +(−0.50s)**, crop 0.43→0.34s, connect_to_nearest 0.33s, registration 0.39s. + +**Caveat — `registration`:** only `large_image` deferred; `StackReg` left at module +top because the tests `patch('entrypoint.StackReg')` and the code references +`StackReg.TRANSLATION/.RIGID_BODY/.AFFINE` class constants. Not worth a test rewrite +for the small pystackreg win. + +### Unused-import sweep +Removed **143 unused module-level imports across 41 entrypoints** (AST: zero +name-references; conservative — single-line top-level imports only, kept used names +in multi-name lines, skipped conditional/parenthesized imports). Most are ~0 startup +(stdlib / `numpy`-shadowed / lazy `skimage`) — pure hygiene. The few real wins were +dead heavy imports: `imageio` (crop / histogram_matching / registration) and +`scipy.spatial.distance` (point_to_nearest_blob_distance). The sweep also covered +deprecated workers — dead `cv2`/`numpy` were removed from `line_length_worker`, +`point_to_nearest_point_distance`, and `point_to_nearest_connected_point_distance` +(these are `$BASE_IMAGE` workers that can't be built locally, so they are +py_compile/reference-validated only). `ai_analysis` was left untouched (slated for +deprecation). + +### Verification +All buildable worker-profile test suites pass — connect family, crop, +blob_random_forest (4), blob_overlap, children_count (7), line_scan (7), +point_to_nearest_blob_distance (13), gaussian_blur (15), h_and_e_deconvolution (9), +histogram_matching (16), rolling_ball (17), registration (20), deconwolf (38), plus +the unchanged-logic property workers. The suspicious removals were confirmed safe +(h_and_e doesn't use skimage's `hed2rgb`; blob_point_count doesn't use the local +`point_in_polygon`; parent_child doesn't use `defaultdict`). GPU workers +(cellpose/sam2/stardist/piscis) are static-validated (py_compile + reference +analysis) only and need amd64 build-host runtime validation. + +### Remaining future work (not done) +- **#6 (multi-stage prune)** — drop `build-essential` / `git` / dev headers from + the final base layers. The riskier image-size win; warrants its own pass. +- ~~**GPU build-host validation** of the deferral + unused-import changes.~~ + **DONE 2026-06-28** — all 13 GPU workers built + smoke-clean on a g4dn host + (see the 2026-06-28 log). +- Deprecated / `$BASE_IMAGE` workers remain on `conda run` (not shipped). + +## Implementation Log — 2026-06-28: GPU build-host validation + piscis fixes + +The item-#2 (deferral) and entrypoint changes were validated on an amd64 GPU +build host (`g4dn.2xlarge`, Tesla T4, driver 580 — the same AMI prod workers +run), building each worker's image and smoke-testing it (`--help` full startup + +an `--gpus all` probe that imports exactly the libs deferred into `compute()`). + +**All 13 GPU workers build + smoke-clean.** The lazy-import refactor is +runtime-clean — no `NameError`/`ImportError` from deferring imports into +`compute()`; every torch worker reports `torch.cuda.is_available() == True`, and +stardist (TF 2.11) sees the GPU. (`--help` exits 0 for all, confirming +`run_worker.sh` activation + module-level imports load.) + +Three piscis-specific issues — none catchable by static validation — were found +and fixed (all on `claude/worker-startup-latency`): + +1. **`run_worker.sh` build-context error (commit `8748cac`).** piscis is the only + GPU worker built via compose with `context: .` = the `workers/annotations/piscis/` + subdir, so the new `COPY ./workers/base_docker_images/run_worker.sh` (correct + for the 12 repo-root-context workers) didn't resolve → both piscis images + failed to build. Fixed by building piscis from repo-root context (compose + `context: ../../..`) and re-rooting the piscis-local `COPY`s. + +2. **Vestigial jax/flax (commit `60afa80`).** A first attempt added `jax[cuda12]` + to "GPU-enable" piscis, but the current piscis is **torch-only**: the worker + entrypoints import only `torch`, and neither piscis 1.0.0 (PyPI) nor the + zjniu/Piscis source declares jax/flax. jax entered solely via a leftover + `pip install flax`. Both the `jax[cuda12]` line and `pip install flax` were + removed; piscis runs on torch (image ~30 GB vs ~45 GB with the jax stack). + +3. **`interface` request paid a ~4 s torch import (commit `bc70262`).** This is + the caveat flagged in the item-#2 log. Both entrypoints **and** the worker's + `utils.py` had module-level `from piscis.paths import MODELS_DIR`, and + `from piscis.paths import …` runs `piscis/__init__.py` → `from piscis.core + import Piscis` → `import torch`. Because the entrypoints `import utils` at + module load (and `interface()` calls `utils.list_girder_models`), torch loaded + on every interface request despite being deferred in `compute()`. Fixed by + defining `MODELS_DIR = Path.home()/'.piscis'/'models'` (plain pathlib, no + piscis import) in `utils.py` and pointing both entrypoints at + `utils.MODELS_DIR`. **Verified on a GPU build:** loading `entrypoint.py` no + longer imports torch (`importtime` clean); interface-path import **0.35–0.41 s + vs ~4.2 s**; model list unchanged; compute path + GPU unaffected. + +**Still untested:** a real end-to-end `compute` run against live Girder image +data — all of the above covers build + startup + import resolution + GPU +visibility, not a full inference pass. Verify piscis predict/train on the +platform opportunistically once this merges. diff --git a/workers/annotations/ai_analysis/Dockerfile b/workers/annotations/ai_analysis/Dockerfile index 96d78ca..1a4a6c6 100644 --- a/workers/annotations/ai_analysis/Dockerfile +++ b/workers/annotations/ai_analysis/Dockerfile @@ -64,5 +64,7 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Claude natural language analyzer" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] - +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/ai_analysis/Dockerfile_M1 b/workers/annotations/ai_analysis/Dockerfile_M1 index e74866d..70ee3de 100644 --- a/workers/annotations/ai_analysis/Dockerfile_M1 +++ b/workers/annotations/ai_analysis/Dockerfile_M1 @@ -64,5 +64,7 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Claude natural language analyzer" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] - +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/annulus_generator_M1/entrypoint.py b/workers/annotations/annulus_generator_M1/entrypoint.py index 5379b46..d62992a 100644 --- a/workers/annotations/annulus_generator_M1/entrypoint.py +++ b/workers/annotations/annulus_generator_M1/entrypoint.py @@ -13,7 +13,6 @@ import numpy as np from skimage import filters -from skimage.feature import peak_local_max from shapely.geometry import Polygon diff --git a/workers/annotations/cellpose/Dockerfile b/workers/annotations/cellpose/Dockerfile index 4f9078c..8b53859 100755 --- a/workers/annotations/cellpose/Dockerfile +++ b/workers/annotations/cellpose/Dockerfile @@ -66,4 +66,7 @@ LABEL isUPennContrastWorker=True \ description="Uses Cellpose to find cells and nuclei" \ defaultToolName="Cellpose" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/cellpose/entrypoint.py b/workers/annotations/cellpose/entrypoint.py index 67777b9..262e2a4 100755 --- a/workers/annotations/cellpose/entrypoint.py +++ b/workers/annotations/cellpose/entrypoint.py @@ -1,20 +1,14 @@ import argparse import json import sys -from pathlib import Path from functools import partial -from itertools import product import annotation_client.workers as workers -from annotation_client.utils import sendError, sendWarning, sendProgress +from annotation_client.utils import sendError -import numpy as np # library for array manipulation -import deeptile -from deeptile.extensions.segmentation import cellpose_segmentation -from deeptile.extensions.stitch import stitch_polygons import girder_utils -from girder_utils import CELLPOSE_DIR, MODELS_DIR +from girder_utils import MODELS_DIR from shapely.geometry import Polygon @@ -154,6 +148,10 @@ def interface(image, apiUrl, token): def run_model(image, cellpose, tile_size, tile_overlap, padding, smoothing): + # Lazy import: keeps deeptile off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import deeptile + from deeptile.extensions.stitch import stitch_polygons + dt = deeptile.load(image) image = dt.get_tiles(tile_size=(tile_size, tile_size), overlap=(tile_overlap, tile_overlap)) @@ -199,6 +197,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps deeptile off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from deeptile.extensions.segmentation import cellpose_segmentation + worker = WorkerClient(datasetId, apiUrl, token, params) # Get the model and diameter from interface values diff --git a/workers/annotations/cellpose_train/Dockerfile b/workers/annotations/cellpose_train/Dockerfile index b5d6c31..d9a9e4c 100755 --- a/workers/annotations/cellpose_train/Dockerfile +++ b/workers/annotations/cellpose_train/Dockerfile @@ -66,4 +66,7 @@ LABEL isUPennContrastWorker="" \ defaultToolName="Cellpose retrain" \ annotationShape="polygon" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/cellpose_train/entrypoint.py b/workers/annotations/cellpose_train/entrypoint.py index 7d3e655..eb2c861 100755 --- a/workers/annotations/cellpose_train/entrypoint.py +++ b/workers/annotations/cellpose_train/entrypoint.py @@ -2,15 +2,10 @@ from collections import defaultdict import json import sys -from pathlib import Path -from functools import partial -from itertools import product from skimage import draw import numpy as np -from shapely.geometry import Polygon, box - -from cellpose import io, models, train, core +from shapely.geometry import Polygon import annotation_client.workers as workers import annotation_client.tiles as tiles @@ -19,7 +14,7 @@ import annotation_utilities.annotation_tools as annotation_tools import girder_utils -from girder_utils import CELLPOSE_DIR, MODELS_DIR +from girder_utils import MODELS_DIR BASE_MODELS = ['cyto', 'cyto2', 'cyto3', 'nuclei'] @@ -140,6 +135,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps cellpose off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from cellpose import io, models, train, core + workerInterface = params['workerInterface'] # Get the model and diameter from interface values diff --git a/workers/annotations/cellposesam/Dockerfile b/workers/annotations/cellposesam/Dockerfile index 856bb1a..ad2ccf0 100644 --- a/workers/annotations/cellposesam/Dockerfile +++ b/workers/annotations/cellposesam/Dockerfile @@ -66,4 +66,7 @@ LABEL isUPennContrastWorker=True \ description="Uses Cellpose-SAM to find cells and nuclei" \ defaultToolName="Cellpose-SAM" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/cellposesam/entrypoint.py b/workers/annotations/cellposesam/entrypoint.py index 7cea7c5..4933cea 100644 --- a/workers/annotations/cellposesam/entrypoint.py +++ b/workers/annotations/cellposesam/entrypoint.py @@ -1,20 +1,14 @@ import argparse import json import sys -from pathlib import Path from functools import partial -from itertools import product import annotation_client.workers as workers -from annotation_client.utils import sendError, sendWarning, sendProgress +from annotation_client.utils import sendError, sendWarning -import numpy as np # library for array manipulation -import deeptile -from deeptile.extensions.segmentation import cellpose_segmentation -from deeptile.extensions.stitch import stitch_polygons import girder_utils -from girder_utils import CELLPOSE_DIR, MODELS_DIR +from girder_utils import MODELS_DIR from shapely.geometry import Polygon @@ -148,6 +142,10 @@ def interface(image, apiUrl, token): def run_model(image, cellpose, tile_size, tile_overlap, padding, smoothing): + # Lazy import: keeps deeptile off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import deeptile + from deeptile.extensions.stitch import stitch_polygons + dt = deeptile.load(image) image = dt.get_tiles(tile_size=(tile_size, tile_size), overlap=(tile_overlap, tile_overlap)) @@ -193,6 +191,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps deeptile off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from deeptile.extensions.segmentation import cellpose_segmentation + worker = WorkerClient(datasetId, apiUrl, token, params) # Get the model and diameter from interface values diff --git a/workers/annotations/condensatenet/Dockerfile b/workers/annotations/condensatenet/Dockerfile index 134efd5..4239c93 100644 --- a/workers/annotations/condensatenet/Dockerfile +++ b/workers/annotations/condensatenet/Dockerfile @@ -72,4 +72,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="Condensates" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/condensatenet/entrypoint.py b/workers/annotations/condensatenet/entrypoint.py index c01ec95..a1dfb71 100644 --- a/workers/annotations/condensatenet/entrypoint.py +++ b/workers/annotations/condensatenet/entrypoint.py @@ -8,17 +8,8 @@ import numpy as np from shapely.geometry import Polygon -import deeptile -from deeptile.core.lift import lift -from deeptile.core.data import Output -from deeptile.core.utils import compute_dask -from deeptile.extensions.segmentation import mask_to_polygons -from deeptile.extensions.stitch import stitch_polygons - from worker_client import WorkerClient, geometry_to_polygon_coords -from condensatenet import CondensateNetPipeline - from annotation_client.utils import sendProgress @@ -155,6 +146,14 @@ def condensatenet_segmentation(prob_threshold, min_size, max_size, model_path): Lifted function for the CondensateNet segmentation algorithm. """ + # Lazy import: keeps deeptile off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from deeptile.core.lift import lift + from deeptile.core.data import Output + from deeptile.core.utils import compute_dask + from deeptile.extensions.segmentation import mask_to_polygons + # Lazy import: keeps the condensatenet package (pulls torch) off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from condensatenet import CondensateNetPipeline + # Load CondensateNet model once sendProgress(0, "Loading model", f"Initializing CondensateNet from {model_path}...") pipeline = CondensateNetPipeline.from_local( @@ -224,6 +223,10 @@ def run_model(image, condensatenet, tile_size, tile_overlap, padding, smoothing) list List of polygon coordinates. """ + # Lazy import: keeps deeptile off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import deeptile + from deeptile.extensions.stitch import stitch_polygons + dt = deeptile.load(image) tiles = dt.get_tiles( tile_size=(tile_size, tile_size), diff --git a/workers/annotations/connect_sequential/Dockerfile b/workers/annotations/connect_sequential/Dockerfile index 3101482..b9f3b66 100644 --- a/workers/annotations/connect_sequential/Dockerfile +++ b/workers/annotations/connect_sequential/Dockerfile @@ -12,4 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Connect Sequential" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/connect_sequential/entrypoint.py b/workers/annotations/connect_sequential/entrypoint.py index 56cb8ec..5108a6a 100644 --- a/workers/annotations/connect_sequential/entrypoint.py +++ b/workers/annotations/connect_sequential/entrypoint.py @@ -1,10 +1,6 @@ -import base64 import argparse import json import sys -import random -import time -import timeit from operator import itemgetter @@ -17,11 +13,8 @@ import annotation_utilities.annotation_tools as annotation_tools import numpy as np -import pandas as pd -import geopandas as gpd -from shapely.geometry import Point, Polygon -from scipy.spatial import cKDTree +from shapely.geometry import Polygon def extract_spatial_annotation_data(obj_list): @@ -52,6 +45,11 @@ def extract_spatial_annotation_data(obj_list): def compute_nearest_child_to_parent(child_df, parent_df, groupby_cols=['Time', 'XY', 'Z'], max_distance=None): + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + # Lazy import: keeps scipy off the interface path; only needed during compute. See todo/worker-startup-latency.md + from scipy.spatial import cKDTree + # Empty DataFrame to store results child_to_parent = pd.DataFrame(columns=['child_id', 'nearest_parent_id']) @@ -177,6 +175,10 @@ def compute(datasetId, apiUrl, token, params): tile: tile position (TODO: roi) ({XY, Z, Time}), connectTo: how new annotations should be connected """ + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + # Lazy import: keeps geopandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import geopandas as gpd # roughly validate params keys = ["assignment", "channel", "connectTo", diff --git a/workers/annotations/connect_timelapse/Dockerfile b/workers/annotations/connect_timelapse/Dockerfile index 5e7f4c8..b85e094 100644 --- a/workers/annotations/connect_timelapse/Dockerfile +++ b/workers/annotations/connect_timelapse/Dockerfile @@ -12,5 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Connect Time Lapse" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] - +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/connect_timelapse/entrypoint.py b/workers/annotations/connect_timelapse/entrypoint.py index 75bdc6e..1b55cf2 100644 --- a/workers/annotations/connect_timelapse/entrypoint.py +++ b/workers/annotations/connect_timelapse/entrypoint.py @@ -11,11 +11,8 @@ import annotation_utilities.annotation_tools as annotation_tools import numpy as np -import pandas as pd -import geopandas as gpd -from shapely.geometry import Point, Polygon -from scipy.spatial import cKDTree +from shapely.geometry import Polygon def interface(image, apiUrl, token): @@ -88,6 +85,11 @@ def extract_spatial_annotation_data(obj_list): def compute_nearest_child_to_parent(child_df, parent_df, max_distance=None): """Compute nearest parents for children, assuming all grouping is done externally""" + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + # Lazy import: keeps scipy off the interface path; only needed during compute. See todo/worker-startup-latency.md + from scipy.spatial import cKDTree + if child_df.empty or parent_df.empty: return pd.DataFrame() @@ -160,6 +162,10 @@ def compute(datasetId, apiUrl, token, params): tile: tile position (TODO: roi) ({XY, Z, Time}), connectTo: how new annotations should be connected """ + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + # Lazy import: keeps geopandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import geopandas as gpd # roughly validate params keys = ["assignment", "channel", "connectTo", diff --git a/workers/annotations/connect_to_nearest/Dockerfile b/workers/annotations/connect_to_nearest/Dockerfile index b42b2e7..91834d2 100644 --- a/workers/annotations/connect_to_nearest/Dockerfile +++ b/workers/annotations/connect_to_nearest/Dockerfile @@ -12,5 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Connect to Nearest" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] - +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/connect_to_nearest/entrypoint.py b/workers/annotations/connect_to_nearest/entrypoint.py index 315bcdd..c5d4dbc 100644 --- a/workers/annotations/connect_to_nearest/entrypoint.py +++ b/workers/annotations/connect_to_nearest/entrypoint.py @@ -1,10 +1,6 @@ -import base64 import argparse import json import sys -import random -import time -import timeit from operator import itemgetter @@ -17,11 +13,8 @@ import annotation_utilities.annotation_tools as annotation_tools import numpy as np -import pandas as pd -import geopandas as gpd from shapely.geometry import Point, Polygon -from scipy.spatial import cKDTree def interface(image, apiUrl, token): @@ -95,6 +88,9 @@ def interface(image, apiUrl, token): def extract_spatial_annotation_data(obj_list): + # Lazy import: keeps geopandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import geopandas as gpd + data = [] for obj in obj_list: shape = obj['shape'] @@ -134,6 +130,13 @@ def compute_nearest_child_to_parent( restrict_connection='None', max_children=None ): + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + # Lazy import: keeps geopandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import geopandas as gpd + # Lazy import: keeps scipy off the interface path; only needed during compute. See todo/worker-startup-latency.md + from scipy.spatial import cKDTree + # Empty DataFrame to store results child_to_parent = pd.DataFrame(columns=['child_id', 'nearest_parent_id']) diff --git a/workers/annotations/crop/Dockerfile b/workers/annotations/crop/Dockerfile index 407ec91..4c006d9 100644 --- a/workers/annotations/crop/Dockerfile +++ b/workers/annotations/crop/Dockerfile @@ -12,4 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Crop" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from image-processing-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/crop/entrypoint.py b/workers/annotations/crop/entrypoint.py index 7a8fe9f..655bd29 100644 --- a/workers/annotations/crop/entrypoint.py +++ b/workers/annotations/crop/entrypoint.py @@ -1,10 +1,7 @@ -import base64 import argparse import json import sys -import pprint -from operator import itemgetter import annotation_client.tiles as tiles import annotation_client.workers as workers @@ -12,16 +9,9 @@ from annotation_client.utils import sendProgress, sendError import annotation_utilities.annotation_tools as annotation_tools -import imageio -import numpy as np -from worker_client import WorkerClient import annotation_utilities.batch_argument_parser as batch_argument_parser -from functools import partial -from skimage import feature, filters, measure, restoration - -import large_image as li def interface(image, apiUrl, token): @@ -98,6 +88,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps large_image off the interface/preview path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li + tileClient = tiles.UPennContrastDataset( apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/deconwolf/Dockerfile b/workers/annotations/deconwolf/Dockerfile index 69d414d..4d37db1 100644 --- a/workers/annotations/deconwolf/Dockerfile +++ b/workers/annotations/deconwolf/Dockerfile @@ -85,4 +85,7 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Deconwolf Deconvolution" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/deconwolf/Dockerfile_M1 b/workers/annotations/deconwolf/Dockerfile_M1 index 9031a5d..2d1037f 100644 --- a/workers/annotations/deconwolf/Dockerfile_M1 +++ b/workers/annotations/deconwolf/Dockerfile_M1 @@ -31,4 +31,7 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Deconwolf Deconvolution" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/deconwolf/entrypoint.py b/workers/annotations/deconwolf/entrypoint.py index 60e4d17..b3991be 100644 --- a/workers/annotations/deconwolf/entrypoint.py +++ b/workers/annotations/deconwolf/entrypoint.py @@ -12,7 +12,6 @@ import numpy as np import tifffile -import large_image as li def interface(image, apiUrl, token): @@ -316,6 +315,9 @@ def get_manual_params(workerInterface): def copy_image_unchanged(tileClient, datasetId, gc): """Copy the image unchanged (for 2D case).""" + # Lazy import: keeps large_image off the interface path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li + sink = li.new() if 'frames' in tileClient.tiles: @@ -347,6 +349,8 @@ def copy_image_unchanged(tileClient, datasetId, gc): def compute(datasetId, apiUrl, token, params): """Main computation function for deconvolution.""" + # Lazy import: keeps large_image off the interface path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li tileClient = tiles.UPennContrastDataset( apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/deepcell/Dockerfile b/workers/annotations/deepcell/Dockerfile index cc62ba5..2cddf5e 100644 --- a/workers/annotations/deepcell/Dockerfile +++ b/workers/annotations/deepcell/Dockerfile @@ -47,4 +47,7 @@ RUN python /download_models.py COPY ./entrypoint.py / -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/deepcell/entrypoint.py b/workers/annotations/deepcell/entrypoint.py index 4cdd592..d6acfec 100755 --- a/workers/annotations/deepcell/entrypoint.py +++ b/workers/annotations/deepcell/entrypoint.py @@ -8,9 +8,6 @@ import annotation_client.tiles as tiles import numpy as np # library for array manipulation -import deeptile -from deeptile.extensions.segmentation import deepcell_mesmer_segmentation -from deeptile.extensions.stitch import stitch_masks from rasterio.features import shapes @@ -38,6 +35,11 @@ def compute(datasetId, apiUrl, token, params): return assignment, channel, connectTo, tags, tile = itemgetter(*keys)(params) + # Lazy import: keeps deeptile off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import deeptile + from deeptile.extensions.segmentation import deepcell_mesmer_segmentation + from deeptile.extensions.stitch import stitch_masks + # Setup helper classes with url and credentials annotationClient = annotations.UPennContrastAnnotationClient( apiUrl=apiUrl, token=token) diff --git a/workers/annotations/gaussian_blur/Dockerfile b/workers/annotations/gaussian_blur/Dockerfile index 0b3e78d..d8bdc87 100644 --- a/workers/annotations/gaussian_blur/Dockerfile +++ b/workers/annotations/gaussian_blur/Dockerfile @@ -12,4 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Gaussian Blur" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from image-processing-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/gaussian_blur/entrypoint.py b/workers/annotations/gaussian_blur/entrypoint.py index 4041e94..8ca300e 100644 --- a/workers/annotations/gaussian_blur/entrypoint.py +++ b/workers/annotations/gaussian_blur/entrypoint.py @@ -2,24 +2,19 @@ import argparse import json import sys -import pprint from operator import itemgetter import annotation_client.tiles as tiles import annotation_client.workers as workers -from annotation_client.utils import sendProgress, sendError +from annotation_client.utils import sendProgress import imageio import numpy as np -from worker_client import WorkerClient -from functools import partial -from skimage import feature, filters, measure - -import large_image as li +from skimage import filters def preview(datasetId, apiUrl, token, params, bimage): @@ -112,6 +107,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps large_image off the interface/preview path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li + tileClient = tiles.UPennContrastDataset( apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/h_and_e_deconvolution/Dockerfile b/workers/annotations/h_and_e_deconvolution/Dockerfile index 936fc3d..9f4c390 100644 --- a/workers/annotations/h_and_e_deconvolution/Dockerfile +++ b/workers/annotations/h_and_e_deconvolution/Dockerfile @@ -12,4 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="H&E Deconvolution" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from image-processing-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/h_and_e_deconvolution/entrypoint.py b/workers/annotations/h_and_e_deconvolution/entrypoint.py index 362a2a5..ff7bc0d 100644 --- a/workers/annotations/h_and_e_deconvolution/entrypoint.py +++ b/workers/annotations/h_and_e_deconvolution/entrypoint.py @@ -1,10 +1,7 @@ -import base64 import argparse import json import sys -import pprint -from operator import itemgetter import annotation_client.tiles as tiles import annotation_client.workers as workers @@ -13,16 +10,10 @@ import numpy as np -from worker_client import WorkerClient -from functools import partial -from skimage import feature, filters, measure, restoration -from skimage.exposure import match_histograms -from skimage import data -from skimage.color import rgb2hed, hed2rgb +from skimage.color import rgb2hed from skimage.exposure import rescale_intensity -import large_image as li def interface(image, apiUrl, token): @@ -61,6 +52,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps large_image off the interface/preview path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li + tileClient = tiles.UPennContrastDataset( apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/histogram_matching/Dockerfile b/workers/annotations/histogram_matching/Dockerfile index 0babb05..172acdb 100644 --- a/workers/annotations/histogram_matching/Dockerfile +++ b/workers/annotations/histogram_matching/Dockerfile @@ -12,4 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Histogram Matching" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from image-processing-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/histogram_matching/entrypoint.py b/workers/annotations/histogram_matching/entrypoint.py index c65b706..3b1801e 100644 --- a/workers/annotations/histogram_matching/entrypoint.py +++ b/workers/annotations/histogram_matching/entrypoint.py @@ -1,27 +1,17 @@ -import base64 import argparse import json import sys -import pprint -from operator import itemgetter import annotation_client.tiles as tiles import annotation_client.workers as workers from annotation_client.utils import sendProgress, sendError -import imageio -import numpy as np -from worker_client import WorkerClient -from functools import partial -from skimage import feature, filters, measure, restoration from skimage.exposure import match_histograms -import large_image as li - def interface(image, apiUrl, token): client = workers.UPennContrastWorkerPreviewClient( @@ -89,6 +79,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps large_image off the interface/preview path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li + tileClient = tiles.UPennContrastDataset( apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/laplacian_of_gaussian/Dockerfile b/workers/annotations/laplacian_of_gaussian/Dockerfile index 5f19cd8..2ab0e86 100644 --- a/workers/annotations/laplacian_of_gaussian/Dockerfile +++ b/workers/annotations/laplacian_of_gaussian/Dockerfile @@ -1,52 +1,6 @@ -FROM ubuntu:jammy as base -LABEL isUPennContrastWorker=True - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -ENV PATH="/root/miniforge3/bin:$PATH" -ARG PATH="/root/miniforge3/bin:$PATH" - -RUN wget \ - https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh \ - && mkdir /root/.conda \ - && bash Miniforge3-Linux-x86_64.sh -b \ - && rm -f Miniforge3-Linux-x86_64.sh - -FROM base as build - -# Accept Anaconda Terms of Service for defaults channel -ENV CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes - -COPY ./workers/annotations/laplacian_of_gaussian/environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -RUN git clone https://github.com/Kitware/UPennContrast/ -RUN pip install -r /UPennContrast/devops/girder/annotation_client/requirements.txt -RUN pip install -e /UPennContrast/devops/girder/annotation_client/ - -COPY ./annotation_utilities /annotation_utilities -WORKDIR /annotation_utilities -RUN pip install . - -COPY ./worker_client /worker_client -WORKDIR /worker_client -RUN pip install . +FROM nimbusimage/worker-base:latest +# Copy the entrypoint script COPY ./workers/annotations/laplacian_of_gaussian/entrypoint.py / LABEL isUPennContrastWorker="" \ @@ -56,4 +10,4 @@ LABEL isUPennContrastWorker="" \ description="Detects spots in images using the Laplacian of Gaussian method" \ hasPreview="True" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) diff --git a/workers/annotations/laplacian_of_gaussian/Dockerfile_M1 b/workers/annotations/laplacian_of_gaussian/Dockerfile_M1 deleted file mode 100644 index 2f68958..0000000 --- a/workers/annotations/laplacian_of_gaussian/Dockerfile_M1 +++ /dev/null @@ -1,61 +0,0 @@ -FROM ubuntu:jammy as base -LABEL isUPennContrastWorker=True - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -# The below is for the M1 Macs and should be changed for other architectures -ENV PATH="/root/miniconda3/bin:$PATH" -ARG PATH="/root/miniconda3/bin:$PATH" - -RUN wget \ - https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-aarch64.sh \ - && mkdir /root/.conda \ - && bash Miniconda3-latest-Linux-aarch64.sh -b \ - && rm -f Miniconda3-latest-Linux-aarch64.sh -# END M1 Mac specific - -FROM base as build - -# Accept Anaconda Terms of Service for defaults channel -ENV CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes - -COPY ./workers/annotations/laplacian_of_gaussian/environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -RUN git clone https://github.com/Kitware/UPennContrast/ -RUN pip install -r /UPennContrast/devops/girder/annotation_client/requirements.txt -RUN pip install -e /UPennContrast/devops/girder/annotation_client/ - -COPY ./annotation_utilities /annotation_utilities -WORKDIR /annotation_utilities -RUN pip install . - -COPY ./worker_client /worker_client -WORKDIR /worker_client -RUN pip install . - -COPY ./workers/annotations/laplacian_of_gaussian/entrypoint.py / - -LABEL isUPennContrastWorker="" \ - isAnnotationWorker="" \ - interfaceName="Laplacian of Gaussian" \ - interfaceCategory="Object Detection" \ - description="Detects spots in images using the Laplacian of Gaussian method" \ - hasPreview="True" - -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/piscis/docker-compose.yaml b/workers/annotations/piscis/docker-compose.yaml index 777e1bd..b5ac707 100644 --- a/workers/annotations/piscis/docker-compose.yaml +++ b/workers/annotations/piscis/docker-compose.yaml @@ -1,11 +1,11 @@ services: predict: build: - context: . - dockerfile: ./predict/Dockerfile + context: ../../.. + dockerfile: ./workers/annotations/piscis/predict/Dockerfile image: annotations/piscis_predict:latest train: build: - context: . - dockerfile: ./train/Dockerfile + context: ../../.. + dockerfile: ./workers/annotations/piscis/train/Dockerfile image: annotations/piscis_train:latest diff --git a/workers/annotations/piscis/predict/Dockerfile b/workers/annotations/piscis/predict/Dockerfile index 0168c35..4673653 100755 --- a/workers/annotations/piscis/predict/Dockerfile +++ b/workers/annotations/piscis/predict/Dockerfile @@ -1,72 +1,74 @@ -FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 as base -LABEL com.nvidia.volumes.needed="nvidia_driver" - -ENV NVIDIA_VISIBLE_DEVICES all -ENV NVIDIA_DRIVER_CAPABILITIES compute,utility - -LABEL isUPennContrastWorker=True -LABEL isAnnotationWorker=True -LABEL interfaceName="Piscis (Predict)" -LABEL interfaceCategory="Piscis" - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -ENV PATH="/root/miniconda3/bin:$PATH" -ARG PATH="/root/miniconda3/bin:$PATH" - -RUN wget \ - https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh \ - && mkdir /root/.conda \ - && bash Miniconda3-latest-Linux-x86_64.sh -b \ - && rm -f Miniconda3-latest-Linux-x86_64.sh - -FROM base as build - -RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main && \ - conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r - -COPY ./environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -RUN git clone https://github.com/Kitware/UPennContrast/ - -RUN pip install -r /UPennContrast/devops/girder/annotation_client/requirements.txt -RUN pip install -e /UPennContrast/devops/girder/annotation_client/ - -RUN pip install piscis==1.0.0 -RUN pip install flax - -WORKDIR / -RUN git clone https://github.com/arjunrajlaboratory/ImageAnalysisProject/ -RUN pip install /ImageAnalysisProject/annotation_utilities -RUN pip install /ImageAnalysisProject/worker_client - -COPY ./download_models.py / -RUN python /download_models.py - -COPY ./utils.py / -COPY ./predict/entrypoint.py / - -LABEL isUPennContrastWorker=True \ - isAnnotationWorker=True \ - interfaceName="Piscis spot detection" \ - interfaceCategory="Piscis" \ - description="Uses Piscis to find spots" \ - defaultToolName="Spots" - -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 as base +LABEL com.nvidia.volumes.needed="nvidia_driver" + +ENV NVIDIA_VISIBLE_DEVICES all +ENV NVIDIA_DRIVER_CAPABILITIES compute,utility + +LABEL isUPennContrastWorker=True +LABEL isAnnotationWorker=True +LABEL interfaceName="Piscis (Predict)" +LABEL interfaceCategory="Piscis" + +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ + apt-get install -qy software-properties-common python3-software-properties && \ + apt-get update && apt-get install -qy \ + build-essential \ + wget \ + python3 \ + r-base \ + libffi-dev \ + libssl-dev \ + libjpeg-dev \ + zlib1g-dev \ + r-base \ + git \ + libpython3-dev && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +ENV PATH="/root/miniconda3/bin:$PATH" +ARG PATH="/root/miniconda3/bin:$PATH" + +RUN wget \ + https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh \ + && mkdir /root/.conda \ + && bash Miniconda3-latest-Linux-x86_64.sh -b \ + && rm -f Miniconda3-latest-Linux-x86_64.sh + +FROM base as build + +RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main && \ + conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r + +COPY ./workers/annotations/piscis/environment.yml / +RUN conda env create --file /environment.yml +SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] + +RUN git clone https://github.com/Kitware/UPennContrast/ + +RUN pip install -r /UPennContrast/devops/girder/annotation_client/requirements.txt +RUN pip install -e /UPennContrast/devops/girder/annotation_client/ + +RUN pip install piscis==1.0.0 + +WORKDIR / +RUN git clone https://github.com/arjunrajlaboratory/ImageAnalysisProject/ +RUN pip install /ImageAnalysisProject/annotation_utilities +RUN pip install /ImageAnalysisProject/worker_client + +COPY ./workers/annotations/piscis/download_models.py / +RUN python /download_models.py + +COPY ./workers/annotations/piscis/utils.py / +COPY ./workers/annotations/piscis/predict/entrypoint.py / + +LABEL isUPennContrastWorker=True \ + isAnnotationWorker=True \ + interfaceName="Piscis spot detection" \ + interfaceCategory="Piscis" \ + description="Uses Piscis to find spots" \ + defaultToolName="Spots" + +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/piscis/predict/entrypoint.py b/workers/annotations/piscis/predict/entrypoint.py index e32e8cc..f2ee71d 100755 --- a/workers/annotations/piscis/predict/entrypoint.py +++ b/workers/annotations/piscis/predict/entrypoint.py @@ -1,173 +1,175 @@ -import utils -from worker_client import WorkerClient -from piscis.paths import MODELS_DIR -from piscis import Piscis -import annotation_client.workers as workers -from functools import partial -import argparse -import json -import sys -import torch - -import os -os.environ['XLA_PYTHON_CLIENT_PREALLOCATE'] = 'False' - - -def interface(image, apiUrl, token): - client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) - - models = sorted(path.stem for path in MODELS_DIR.glob('*')) - girder_models = [model['model_name'] for model in utils.list_girder_models(client.client)[0]] - models = sorted(list(set(models + girder_models))) - - # Available types: number, text, tags, layer - interface = { - 'Piscis': { - 'type': 'notes', - 'value': 'This tool uses the Piscis model to find points in images. ' - 'It can be used to segment in 2D or 3D. ' - 'Learn more', - 'displayOrder': 0, - }, - 'Batch XY': { - 'type': 'text', - 'vueAttrs': { - 'placeholder': 'ex. 1-3, 5-8', - 'label': 'Enter the XY positions you want to iterate over', - 'persistentPlaceholder': True, - 'filled': True, - }, - 'displayOrder': 1, - }, - 'Batch Z': { - 'type': 'text', - 'vueAttrs': { - 'placeholder': 'ex. 1-3, 5-8', - 'label': 'Enter the Z slices you want to iterate over', - 'persistentPlaceholder': True, - 'filled': True, - }, - 'displayOrder': 2, - }, - 'Batch Time': { - 'type': 'text', - 'vueAttrs': { - 'placeholder': 'ex. 1-3, 5-8', - 'label': 'Enter the Time points you want to iterate over', - 'persistentPlaceholder': True, - 'filled': True, - }, - 'displayOrder': 3, - }, - 'Model': { - 'type': 'select', - 'items': models, - 'default': '20251212', - 'tooltip': 'Select the model to use for segmentation. These can be pre-trained models or\nmodels you have generated yourself with Piscis Train. ' - 'The model determines how sensitive the point detection is.', - 'noCache': True, - 'displayOrder': 5, - }, - 'Mode': { - 'type': 'select', - 'items': ['Current Z', 'Z-Stack'], - 'default': 'Current Z', - 'tooltip': 'If you want to segment in 3D, select "Z-Stack". Otherwise, if you just want to segment in each z-slice individually, select "Current Z".\n' - 'If you select "Z-Stack", then the model will be used to segment all z-slices at once, and it will ignore the "Batch Z" field.', - 'displayOrder': 7, - }, - 'Scale': { - 'type': 'number', - 'min': 0, - 'max': 5, - 'default': 1, - 'tooltip': 'This parameter controls the size of the objects that are detected.\n' - 'It is a multiplier on the size of the objects in the model.', - 'displayOrder': 9, - }, - 'Threshold': { - 'type': 'number', - 'min': 0, - 'max': 9, - 'default': 0.5, - 'tooltip': 'The threshold parameter honestly does not change much.\nUse a different model if you need to change specificity.', - 'displayOrder': 11, - }, - 'Skip Frames Without': { - 'type': 'tags', - 'tooltip': 'Sometimes you may want to skip processing frames that do not have any objects of a particular tag.\n' - 'If empty, all frames will be processed.', - 'displayOrder': 13, - } - } - # Send the interface object to the server - client.setWorkerImageInterface(image, interface) - - -def run_model(image, model, stack, scale, threshold): - - coords = model.predict(image, stack=stack, scale=scale, - threshold=threshold, intermediates=False) - coords[:, -2:] += 0.5 - - return coords - - -def compute(datasetId, apiUrl, token, params): - """ - params (could change): - configurationId, - datasetId, - description: tool description, - type: tool type, - id: tool id, - name: tool name, - image: docker image, - channel: annotation channel, - assignment: annotation assignment ({XY, Z, Time}), - tags: annotation tags (list of strings), - tile: tile position (TODO: roi) ({XY, Z, Time}), - connectTo: how new annotations should be connected - """ - - worker = WorkerClient(datasetId, apiUrl, token, params) - - # Get the Gaussian sigma and threshold from interface values - model_name = worker.workerInterface['Model'] - stack = worker.workerInterface['Mode'] == 'Z-Stack' - scale = float(worker.workerInterface['Scale']) - threshold = float(worker.workerInterface['Threshold']) - - gc = worker.annotationClient.client - utils.download_girder_model(gc, model_name) - - model = Piscis(model_name=model_name, batch_size=1, device='cuda' if torch.cuda.is_available() else 'cpu') - f_process = partial(run_model, model=model, stack=stack, scale=scale, threshold=threshold) - worker.process(f_process, f_annotation='point', - stack_zs='all' if stack else None, progress_text='Running Piscis') - - -if __name__ == '__main__': - # Define the command-line interface for the entry point - parser = argparse.ArgumentParser( - description='Compute average intensity values in a circle around point annotations') - - parser.add_argument('--datasetId', type=str, required=False, action='store') - parser.add_argument('--apiUrl', type=str, required=True, action='store') - parser.add_argument('--token', type=str, required=True, action='store') - parser.add_argument('--request', type=str, required=True, action='store') - parser.add_argument('--parameters', type=str, - required=True, action='store') - - args = parser.parse_args(sys.argv[1:]) - - params = json.loads(args.parameters) - datasetId = args.datasetId - apiUrl = args.apiUrl - token = args.token - - match args.request: - case 'compute': - compute(datasetId, apiUrl, token, params) - case 'interface': - interface(params['image'], apiUrl, token) +import utils +from worker_client import WorkerClient +import annotation_client.workers as workers +from functools import partial +import argparse +import json +import sys + +import os +os.environ['XLA_PYTHON_CLIENT_PREALLOCATE'] = 'False' + + +def interface(image, apiUrl, token): + client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) + + models = sorted(path.stem for path in utils.MODELS_DIR.glob('*')) + girder_models = [model['model_name'] for model in utils.list_girder_models(client.client)[0]] + models = sorted(list(set(models + girder_models))) + + # Available types: number, text, tags, layer + interface = { + 'Piscis': { + 'type': 'notes', + 'value': 'This tool uses the Piscis model to find points in images. ' + 'It can be used to segment in 2D or 3D. ' + 'Learn more', + 'displayOrder': 0, + }, + 'Batch XY': { + 'type': 'text', + 'vueAttrs': { + 'placeholder': 'ex. 1-3, 5-8', + 'label': 'Enter the XY positions you want to iterate over', + 'persistentPlaceholder': True, + 'filled': True, + }, + 'displayOrder': 1, + }, + 'Batch Z': { + 'type': 'text', + 'vueAttrs': { + 'placeholder': 'ex. 1-3, 5-8', + 'label': 'Enter the Z slices you want to iterate over', + 'persistentPlaceholder': True, + 'filled': True, + }, + 'displayOrder': 2, + }, + 'Batch Time': { + 'type': 'text', + 'vueAttrs': { + 'placeholder': 'ex. 1-3, 5-8', + 'label': 'Enter the Time points you want to iterate over', + 'persistentPlaceholder': True, + 'filled': True, + }, + 'displayOrder': 3, + }, + 'Model': { + 'type': 'select', + 'items': models, + 'default': '20251212', + 'tooltip': 'Select the model to use for segmentation. These can be pre-trained models or\nmodels you have generated yourself with Piscis Train. ' + 'The model determines how sensitive the point detection is.', + 'noCache': True, + 'displayOrder': 5, + }, + 'Mode': { + 'type': 'select', + 'items': ['Current Z', 'Z-Stack'], + 'default': 'Current Z', + 'tooltip': 'If you want to segment in 3D, select "Z-Stack". Otherwise, if you just want to segment in each z-slice individually, select "Current Z".\n' + 'If you select "Z-Stack", then the model will be used to segment all z-slices at once, and it will ignore the "Batch Z" field.', + 'displayOrder': 7, + }, + 'Scale': { + 'type': 'number', + 'min': 0, + 'max': 5, + 'default': 1, + 'tooltip': 'This parameter controls the size of the objects that are detected.\n' + 'It is a multiplier on the size of the objects in the model.', + 'displayOrder': 9, + }, + 'Threshold': { + 'type': 'number', + 'min': 0, + 'max': 9, + 'default': 0.5, + 'tooltip': 'The threshold parameter honestly does not change much.\nUse a different model if you need to change specificity.', + 'displayOrder': 11, + }, + 'Skip Frames Without': { + 'type': 'tags', + 'tooltip': 'Sometimes you may want to skip processing frames that do not have any objects of a particular tag.\n' + 'If empty, all frames will be processed.', + 'displayOrder': 13, + } + } + # Send the interface object to the server + client.setWorkerImageInterface(image, interface) + + +def run_model(image, model, stack, scale, threshold): + + coords = model.predict(image, stack=stack, scale=scale, + threshold=threshold, intermediates=False) + coords[:, -2:] += 0.5 + + return coords + + +def compute(datasetId, apiUrl, token, params): + """ + params (could change): + configurationId, + datasetId, + description: tool description, + type: tool type, + id: tool id, + name: tool name, + image: docker image, + channel: annotation channel, + assignment: annotation assignment ({XY, Z, Time}), + tags: annotation tags (list of strings), + tile: tile position (TODO: roi) ({XY, Z, Time}), + connectTo: how new annotations should be connected + """ + + # Lazy import: keeps torch off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + # Lazy import: keeps piscis off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from piscis import Piscis + + worker = WorkerClient(datasetId, apiUrl, token, params) + + # Get the Gaussian sigma and threshold from interface values + model_name = worker.workerInterface['Model'] + stack = worker.workerInterface['Mode'] == 'Z-Stack' + scale = float(worker.workerInterface['Scale']) + threshold = float(worker.workerInterface['Threshold']) + + gc = worker.annotationClient.client + utils.download_girder_model(gc, model_name) + + model = Piscis(model_name=model_name, batch_size=1, device='cuda' if torch.cuda.is_available() else 'cpu') + f_process = partial(run_model, model=model, stack=stack, scale=scale, threshold=threshold) + worker.process(f_process, f_annotation='point', + stack_zs='all' if stack else None, progress_text='Running Piscis') + + +if __name__ == '__main__': + # Define the command-line interface for the entry point + parser = argparse.ArgumentParser( + description='Compute average intensity values in a circle around point annotations') + + parser.add_argument('--datasetId', type=str, required=False, action='store') + parser.add_argument('--apiUrl', type=str, required=True, action='store') + parser.add_argument('--token', type=str, required=True, action='store') + parser.add_argument('--request', type=str, required=True, action='store') + parser.add_argument('--parameters', type=str, + required=True, action='store') + + args = parser.parse_args(sys.argv[1:]) + + params = json.loads(args.parameters) + datasetId = args.datasetId + apiUrl = args.apiUrl + token = args.token + + match args.request: + case 'compute': + compute(datasetId, apiUrl, token, params) + case 'interface': + interface(params['image'], apiUrl, token) diff --git a/workers/annotations/piscis/train/Dockerfile b/workers/annotations/piscis/train/Dockerfile index 2995952..6979d10 100755 --- a/workers/annotations/piscis/train/Dockerfile +++ b/workers/annotations/piscis/train/Dockerfile @@ -1,77 +1,79 @@ -FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 as base -LABEL com.nvidia.volumes.needed="nvidia_driver" - -ENV NVIDIA_VISIBLE_DEVICES all -ENV NVIDIA_DRIVER_CAPABILITIES compute,utility - -LABEL isUPennContrastWorker=True -LABEL isAnnotationWorker=True -LABEL interfaceName="Piscis (Train)" -LABEL interfaceCategory="Piscis" - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -ENV PATH="/root/miniconda3/bin:$PATH" -ARG PATH="/root/miniconda3/bin:$PATH" - -RUN wget \ - https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh \ - && mkdir /root/.conda \ - && bash Miniconda3-latest-Linux-x86_64.sh -b \ - && rm -f Miniconda3-latest-Linux-x86_64.sh - -FROM base as build - -RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main && \ - conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r - -COPY ./environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -RUN git clone https://github.com/arjunrajlaboratory/NimbusImage - -RUN pip install -r /NimbusImage/devops/girder/annotation_client/requirements.txt -RUN pip install -e /NimbusImage/devops/girder/annotation_client/ - - -RUN git clone https://github.com/zjniu/Piscis.git -WORKDIR /Piscis -RUN pip install . -RUN pip install flax - -WORKDIR / -RUN pip install rasterio shapely - -RUN git clone https://github.com/arjunrajlaboratory/ImageAnalysisProject/ -RUN pip install /ImageAnalysisProject/annotation_utilities -RUN pip install /ImageAnalysisProject/worker_client - -COPY ./download_models.py / -RUN python /download_models.py - -COPY ./utils.py / -COPY ./train/entrypoint.py / - -LABEL isUPennContrastWorker=True \ - isAnnotationWorker=True \ - interfaceName="Piscis spot training" \ - interfaceCategory="Piscis" \ - description="Retrain Piscis to detect your particular spots" \ - defaultToolName="Piscis training" - -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 as base +LABEL com.nvidia.volumes.needed="nvidia_driver" + +ENV NVIDIA_VISIBLE_DEVICES all +ENV NVIDIA_DRIVER_CAPABILITIES compute,utility + +LABEL isUPennContrastWorker=True +LABEL isAnnotationWorker=True +LABEL interfaceName="Piscis (Train)" +LABEL interfaceCategory="Piscis" + +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ + apt-get install -qy software-properties-common python3-software-properties && \ + apt-get update && apt-get install -qy \ + build-essential \ + wget \ + python3 \ + r-base \ + libffi-dev \ + libssl-dev \ + libjpeg-dev \ + zlib1g-dev \ + r-base \ + git \ + libpython3-dev && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +ENV PATH="/root/miniconda3/bin:$PATH" +ARG PATH="/root/miniconda3/bin:$PATH" + +RUN wget \ + https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh \ + && mkdir /root/.conda \ + && bash Miniconda3-latest-Linux-x86_64.sh -b \ + && rm -f Miniconda3-latest-Linux-x86_64.sh + +FROM base as build + +RUN conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main && \ + conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r + +COPY ./workers/annotations/piscis/environment.yml / +RUN conda env create --file /environment.yml +SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] + +RUN git clone https://github.com/arjunrajlaboratory/NimbusImage + +RUN pip install -r /NimbusImage/devops/girder/annotation_client/requirements.txt +RUN pip install -e /NimbusImage/devops/girder/annotation_client/ + + +RUN git clone https://github.com/zjniu/Piscis.git +WORKDIR /Piscis +RUN pip install . + +WORKDIR / +RUN pip install rasterio shapely + +RUN git clone https://github.com/arjunrajlaboratory/ImageAnalysisProject/ +RUN pip install /ImageAnalysisProject/annotation_utilities +RUN pip install /ImageAnalysisProject/worker_client + +COPY ./workers/annotations/piscis/download_models.py / +RUN python /download_models.py + +COPY ./workers/annotations/piscis/utils.py / +COPY ./workers/annotations/piscis/train/entrypoint.py / + +LABEL isUPennContrastWorker=True \ + isAnnotationWorker=True \ + interfaceName="Piscis spot training" \ + interfaceCategory="Piscis" \ + description="Retrain Piscis to detect your particular spots" \ + defaultToolName="Piscis training" + +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/piscis/train/entrypoint.py b/workers/annotations/piscis/train/entrypoint.py index 3894f44..b6a8853 100755 --- a/workers/annotations/piscis/train/entrypoint.py +++ b/workers/annotations/piscis/train/entrypoint.py @@ -1,266 +1,268 @@ -import argparse -import json -import sys -import datetime - -import numpy as np -import torch -from rasterio.features import rasterize -from shapely.geometry import Polygon - -import annotation_client.workers as workers - -from annotation_client.utils import sendError, sendWarning, sendProgress - -from piscis.data import generate_dataset -from piscis.paths import MODELS_DIR -from piscis.training import train_model -from piscis.utils import fit_coords, remove_duplicate_coords, snap_coords - -from annotation_utilities.point_in_polygon import point_in_polygon - -import utils - - -def interface(image, apiUrl, token): - client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) - - models = sorted(path.stem for path in MODELS_DIR.glob('*')) - girder_models = [model['model_name'] for model in utils.list_girder_models(client.client)[0]] - models = sorted(list(set(models + girder_models))) - - current_datetime = datetime.datetime.now() - datetime_string = current_datetime.strftime('%Y%m%d_%H%M%S') - - # Available types: number, text, tags, layer - interface = { - 'Piscis Train': { - 'type': 'notes', - 'value': 'This tool trains a Piscis model using user-corrected annotations. ' - 'Learn more', - 'displayOrder': 0, - }, - 'Initial Model Name': { - 'type': 'select', - 'items': models, - 'default': '20251212', - 'displayOrder': 1, - }, - 'Learning Rate': { - 'type': 'text', - 'default': 0.1, - 'displayOrder': 5, - }, - 'Weight Decay': { - 'type': 'text', - 'default': 0.0001, - 'displayOrder': 6, - }, - 'Epochs': { - 'type': 'text', - 'default': 40, - 'displayOrder': 7, - }, - 'Random Seed': { - 'type': 'text', - 'default': 42, - 'displayOrder': 8, - }, - 'New Model Name': { - 'type': 'text', - 'default': datetime_string, - 'displayOrder': 2, - }, - 'Annotation Tag': { - 'type': 'tags', - 'displayOrder': 3, - }, - 'Region Tag': { - 'type': 'tags', - 'displayOrder': 4, - } - } - # Send the interface object to the server - client.setWorkerImageInterface(image, interface) - - -def compute(datasetId, apiUrl, token, params): - """ - params (could change): - configurationId, - datasetId, - description: tool description, - type: tool type, - id: tool id, - name: tool name, - image: docker image, - channel: annotation channel, - assignment: annotation assignment ({XY, Z, Time}), - tags: annotation tags (list of strings), - tile: tile position (TODO: roi) ({XY, Z, Time}), - connectTo: how new annotations should be connected - """ - - workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) - annotationClient = workerClient.annotationClient - - # Get the Gaussian sigma and threshold from interface values - workerInterface = params['workerInterface'] - initial_model_name = workerInterface['Initial Model Name'] - learning_rate = float(workerInterface['Learning Rate']) - weight_decay = float(workerInterface['Weight Decay']) - epochs = int(workerInterface['Epochs']) - random_seed = int(workerInterface['Random Seed']) - new_model_name = workerInterface['New Model Name'] - annotation_tag = workerInterface['Annotation Tag'] - region_tag = workerInterface['Region Tag'] - - # Check if tags are set - if not annotation_tag or len(annotation_tag) == 0: - sendError("No annotation tag selected.", - info="Please select at least one annotation tag.") - raise ValueError("No annotation tag selected.") - - if not region_tag or len(region_tag) == 0: - sendError("No region tag selected.", - info="Please select at least one region tag.") - raise ValueError("No region tag selected.") - - annotationList = annotationClient.getAnnotationsByDatasetId( - datasetId, shape='point', tags=json.dumps(annotation_tag)) - - # Check if any annotations were found - if not annotationList or len(annotationList) == 0: - sendError("No annotations found with the selected annotation tag.", - info=f"No point annotations found with tag(s): {annotation_tag}. Please check your annotation tags.") - raise ValueError("No annotations found with the selected annotation tag.") - - points = np.array([[point['location'][i] - for i in ['Time', 'XY', 'Z']] + list(point['coordinates'][0].values())[1::-1] - for point in annotationList]) - points[:, -2:] -= np.array((0.5, 0.5)) - - regionList = annotationClient.getAnnotationsByDatasetId( - datasetId, shape='polygon', tags=json.dumps(region_tag)) - regionList.extend(annotationClient.getAnnotationsByDatasetId( - datasetId, shape='rectangle', tags=json.dumps(region_tag))) - - # Check if any regions were found - if not regionList or len(regionList) == 0: - sendError("No regions found with the selected region tag.", - info=f"No polygon or rectangle annotations found with tag(s): {region_tag}. Please check your region tags.") - raise ValueError("No regions found with the selected region tag.") - - images = [] - coords = [] - - for region in regionList: - - image = workerClient.get_image_for_annotation(region) - - polygon = np.array([[coordinate[i] for i in ['y', 'x']] - for coordinate in region['coordinates']]) - np.array((0.5, 0.5)) - polygony, polygonx = polygon.T - minx, miny, maxx, maxy = np.min(polygonx), np.min( - polygony), np.max(polygonx), np.max(polygony) - minx, miny, maxx, maxy = np.maximum(minx, -0.5), np.maximum(miny, -0.5), np.minimum( - maxx, image.shape[1] - 0.5), np.minimum(maxy, image.shape[0] - 0.5) - mini, minj, maxi, maxj = round(miny), round(minx), round(maxy), round(maxx) - shapely_polygon = Polygon(np.stack([polygonx - minj, polygony - mini]).T) - image = image[mini:maxi + 1, minj:maxj + 1] - - mask = rasterize([(shapely_polygon, 1)], out_shape=image.shape, - all_touched=True, dtype=np.uint8) - image = image * mask - - c = points[np.all(points[:, :3] == np.array([region['location'][i] - for i in ['Time', 'XY', 'Z']]), axis=1)][:, -2:] - c = c[point_in_polygon(c, polygon)] - np.array([mini, minj]) - c = np.array(c) - - # Check if this region has any points - if c.size == 0 or (c.ndim == 1 and len(c) == 0): - sendError("Region with no points found.", - info="Every training region must contain at least one point annotation. Please ensure all regions have points inside them.") - raise ValueError("Region with no points found.") - - # Ensure c is 2D for the coordinate processing functions - if c.ndim == 1: - if len(c) == 0: - sendError("Region with no points found.", - info="Every training region must contain at least one point annotation. Please ensure all regions have points inside them.") - raise ValueError("Region with no points found.") - # If it's 1D but has data, it might be a single point, reshape it - c = c.reshape(1, -1) - - c = snap_coords(c, image) - c = fit_coords(c, image) - c = remove_duplicate_coords(c) - - # Final check after processing - ensure we still have points - if c.size == 0 or len(c) == 0: - sendError("Region with no valid points after processing.", - info="After coordinate processing, a region ended up with no valid points. Please check your annotations and region boundaries.") - raise ValueError("Region with no valid points after processing.") - - images.append(image) - coords.append(c) - - # Check if we have any data to train with - if len(images) == 0 or len(coords) == 0: - sendError("No training data available.", - info="No valid training data could be extracted from the annotations and regions. Please check your annotations.") - raise ValueError("No training data available.") - - # Check if all coordinate arrays are empty (would cause training to fail) - total_points = sum(len(coord_array) for coord_array in coords) - if total_points == 0: - sendError("No valid points found in any region.", - info="No valid points were found in any of the training regions. Please ensure your regions contain point annotations.") - raise ValueError("No valid points found in any region.") - - sq = np.random.SeedSequence(random_seed) - child_seeds = sq.generate_state(2, dtype=np.uint32) - generate_dataset(new_model_name, images, coords, int(child_seeds[0]), train_size=1., test_size=0.) - - gc = annotationClient.client - utils.download_girder_model(gc, initial_model_name) - - train_model( - model_name=new_model_name, - dataset_path=new_model_name, - initial_model_name=initial_model_name, - random_seed=int(child_seeds[1]), - learning_rate=learning_rate, - weight_decay=weight_decay, - epochs=epochs, - warmup_fraction=0.1, - device='cuda' if torch.cuda.is_available() else 'cpu' - ) - - utils.upload_girder_model(gc, new_model_name) - - -if __name__ == '__main__': - # Define the command-line interface for the entry point - parser = argparse.ArgumentParser( - description='Compute average intensity values in a circle around point annotations') - - parser.add_argument('--datasetId', type=str, required=False, action='store') - parser.add_argument('--apiUrl', type=str, required=True, action='store') - parser.add_argument('--token', type=str, required=True, action='store') - parser.add_argument('--request', type=str, required=True, action='store') - parser.add_argument('--parameters', type=str, - required=True, action='store') - - args = parser.parse_args(sys.argv[1:]) - - params = json.loads(args.parameters) - datasetId = args.datasetId - apiUrl = args.apiUrl - token = args.token - - if args.request == 'compute': - compute(datasetId, apiUrl, token, params) - elif args.request == 'interface': - interface(params['image'], apiUrl, token) +import argparse +import json +import sys +import datetime + +import numpy as np +from shapely.geometry import Polygon + +import annotation_client.workers as workers + +from annotation_client.utils import sendError + +from annotation_utilities.point_in_polygon import point_in_polygon + +import utils + + +def interface(image, apiUrl, token): + client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) + + models = sorted(path.stem for path in utils.MODELS_DIR.glob('*')) + girder_models = [model['model_name'] for model in utils.list_girder_models(client.client)[0]] + models = sorted(list(set(models + girder_models))) + + current_datetime = datetime.datetime.now() + datetime_string = current_datetime.strftime('%Y%m%d_%H%M%S') + + # Available types: number, text, tags, layer + interface = { + 'Piscis Train': { + 'type': 'notes', + 'value': 'This tool trains a Piscis model using user-corrected annotations. ' + 'Learn more', + 'displayOrder': 0, + }, + 'Initial Model Name': { + 'type': 'select', + 'items': models, + 'default': '20251212', + 'displayOrder': 1, + }, + 'Learning Rate': { + 'type': 'text', + 'default': 0.1, + 'displayOrder': 5, + }, + 'Weight Decay': { + 'type': 'text', + 'default': 0.0001, + 'displayOrder': 6, + }, + 'Epochs': { + 'type': 'text', + 'default': 40, + 'displayOrder': 7, + }, + 'Random Seed': { + 'type': 'text', + 'default': 42, + 'displayOrder': 8, + }, + 'New Model Name': { + 'type': 'text', + 'default': datetime_string, + 'displayOrder': 2, + }, + 'Annotation Tag': { + 'type': 'tags', + 'displayOrder': 3, + }, + 'Region Tag': { + 'type': 'tags', + 'displayOrder': 4, + } + } + # Send the interface object to the server + client.setWorkerImageInterface(image, interface) + + +def compute(datasetId, apiUrl, token, params): + """ + params (could change): + configurationId, + datasetId, + description: tool description, + type: tool type, + id: tool id, + name: tool name, + image: docker image, + channel: annotation channel, + assignment: annotation assignment ({XY, Z, Time}), + tags: annotation tags (list of strings), + tile: tile position (TODO: roi) ({XY, Z, Time}), + connectTo: how new annotations should be connected + """ + + # Lazy import: keeps rasterio off the interface path; only needed during compute. See todo/worker-startup-latency.md + from rasterio.features import rasterize + # Lazy import: keeps torch off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + # Lazy import: keeps piscis off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from piscis.data import generate_dataset + from piscis.training import train_model + from piscis.utils import fit_coords, remove_duplicate_coords, snap_coords + + workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) + annotationClient = workerClient.annotationClient + + # Get the Gaussian sigma and threshold from interface values + workerInterface = params['workerInterface'] + initial_model_name = workerInterface['Initial Model Name'] + learning_rate = float(workerInterface['Learning Rate']) + weight_decay = float(workerInterface['Weight Decay']) + epochs = int(workerInterface['Epochs']) + random_seed = int(workerInterface['Random Seed']) + new_model_name = workerInterface['New Model Name'] + annotation_tag = workerInterface['Annotation Tag'] + region_tag = workerInterface['Region Tag'] + + # Check if tags are set + if not annotation_tag or len(annotation_tag) == 0: + sendError("No annotation tag selected.", + info="Please select at least one annotation tag.") + raise ValueError("No annotation tag selected.") + + if not region_tag or len(region_tag) == 0: + sendError("No region tag selected.", + info="Please select at least one region tag.") + raise ValueError("No region tag selected.") + + annotationList = annotationClient.getAnnotationsByDatasetId( + datasetId, shape='point', tags=json.dumps(annotation_tag)) + + # Check if any annotations were found + if not annotationList or len(annotationList) == 0: + sendError("No annotations found with the selected annotation tag.", + info=f"No point annotations found with tag(s): {annotation_tag}. Please check your annotation tags.") + raise ValueError("No annotations found with the selected annotation tag.") + + points = np.array([[point['location'][i] + for i in ['Time', 'XY', 'Z']] + list(point['coordinates'][0].values())[1::-1] + for point in annotationList]) + points[:, -2:] -= np.array((0.5, 0.5)) + + regionList = annotationClient.getAnnotationsByDatasetId( + datasetId, shape='polygon', tags=json.dumps(region_tag)) + regionList.extend(annotationClient.getAnnotationsByDatasetId( + datasetId, shape='rectangle', tags=json.dumps(region_tag))) + + # Check if any regions were found + if not regionList or len(regionList) == 0: + sendError("No regions found with the selected region tag.", + info=f"No polygon or rectangle annotations found with tag(s): {region_tag}. Please check your region tags.") + raise ValueError("No regions found with the selected region tag.") + + images = [] + coords = [] + + for region in regionList: + + image = workerClient.get_image_for_annotation(region) + + polygon = np.array([[coordinate[i] for i in ['y', 'x']] + for coordinate in region['coordinates']]) - np.array((0.5, 0.5)) + polygony, polygonx = polygon.T + minx, miny, maxx, maxy = np.min(polygonx), np.min( + polygony), np.max(polygonx), np.max(polygony) + minx, miny, maxx, maxy = np.maximum(minx, -0.5), np.maximum(miny, -0.5), np.minimum( + maxx, image.shape[1] - 0.5), np.minimum(maxy, image.shape[0] - 0.5) + mini, minj, maxi, maxj = round(miny), round(minx), round(maxy), round(maxx) + shapely_polygon = Polygon(np.stack([polygonx - minj, polygony - mini]).T) + image = image[mini:maxi + 1, minj:maxj + 1] + + mask = rasterize([(shapely_polygon, 1)], out_shape=image.shape, + all_touched=True, dtype=np.uint8) + image = image * mask + + c = points[np.all(points[:, :3] == np.array([region['location'][i] + for i in ['Time', 'XY', 'Z']]), axis=1)][:, -2:] + c = c[point_in_polygon(c, polygon)] - np.array([mini, minj]) + c = np.array(c) + + # Check if this region has any points + if c.size == 0 or (c.ndim == 1 and len(c) == 0): + sendError("Region with no points found.", + info="Every training region must contain at least one point annotation. Please ensure all regions have points inside them.") + raise ValueError("Region with no points found.") + + # Ensure c is 2D for the coordinate processing functions + if c.ndim == 1: + if len(c) == 0: + sendError("Region with no points found.", + info="Every training region must contain at least one point annotation. Please ensure all regions have points inside them.") + raise ValueError("Region with no points found.") + # If it's 1D but has data, it might be a single point, reshape it + c = c.reshape(1, -1) + + c = snap_coords(c, image) + c = fit_coords(c, image) + c = remove_duplicate_coords(c) + + # Final check after processing - ensure we still have points + if c.size == 0 or len(c) == 0: + sendError("Region with no valid points after processing.", + info="After coordinate processing, a region ended up with no valid points. Please check your annotations and region boundaries.") + raise ValueError("Region with no valid points after processing.") + + images.append(image) + coords.append(c) + + # Check if we have any data to train with + if len(images) == 0 or len(coords) == 0: + sendError("No training data available.", + info="No valid training data could be extracted from the annotations and regions. Please check your annotations.") + raise ValueError("No training data available.") + + # Check if all coordinate arrays are empty (would cause training to fail) + total_points = sum(len(coord_array) for coord_array in coords) + if total_points == 0: + sendError("No valid points found in any region.", + info="No valid points were found in any of the training regions. Please ensure your regions contain point annotations.") + raise ValueError("No valid points found in any region.") + + sq = np.random.SeedSequence(random_seed) + child_seeds = sq.generate_state(2, dtype=np.uint32) + generate_dataset(new_model_name, images, coords, int(child_seeds[0]), train_size=1., test_size=0.) + + gc = annotationClient.client + utils.download_girder_model(gc, initial_model_name) + + train_model( + model_name=new_model_name, + dataset_path=new_model_name, + initial_model_name=initial_model_name, + random_seed=int(child_seeds[1]), + learning_rate=learning_rate, + weight_decay=weight_decay, + epochs=epochs, + warmup_fraction=0.1, + device='cuda' if torch.cuda.is_available() else 'cpu' + ) + + utils.upload_girder_model(gc, new_model_name) + + +if __name__ == '__main__': + # Define the command-line interface for the entry point + parser = argparse.ArgumentParser( + description='Compute average intensity values in a circle around point annotations') + + parser.add_argument('--datasetId', type=str, required=False, action='store') + parser.add_argument('--apiUrl', type=str, required=True, action='store') + parser.add_argument('--token', type=str, required=True, action='store') + parser.add_argument('--request', type=str, required=True, action='store') + parser.add_argument('--parameters', type=str, + required=True, action='store') + + args = parser.parse_args(sys.argv[1:]) + + params = json.loads(args.parameters) + datasetId = args.datasetId + apiUrl = args.apiUrl + token = args.token + + if args.request == 'compute': + compute(datasetId, apiUrl, token, params) + elif args.request == 'interface': + interface(params['image'], apiUrl, token) diff --git a/workers/annotations/piscis/utils.py b/workers/annotations/piscis/utils.py index 6566111..8322080 100644 --- a/workers/annotations/piscis/utils.py +++ b/workers/annotations/piscis/utils.py @@ -1,4 +1,12 @@ -from piscis.paths import MODELS_DIR +from pathlib import Path + +# Local path to the downloaded Piscis models. Mirrors piscis.paths.MODELS_DIR +# (= ~/.piscis/models) but WITHOUT importing the piscis package: `import piscis` +# runs piscis/__init__.py -> `from piscis.core import Piscis` -> `import torch`, +# which would make the lightweight `interface` request pay the full multi-second +# torch import for nothing. Keeping this a plain path lets interface() list +# models torch-free; compute() loads piscis/torch lazily when it actually runs. +MODELS_DIR = Path.home() / '.piscis' / 'models' def mkdir(gc, parent_id, folder_name): diff --git a/workers/annotations/random_point/entrypoint.py b/workers/annotations/random_point/entrypoint.py index 94a1513..2690c98 100644 --- a/workers/annotations/random_point/entrypoint.py +++ b/workers/annotations/random_point/entrypoint.py @@ -15,10 +15,7 @@ import numpy as np from skimage import filters -from skimage.feature import peak_local_max -from shapely.geometry import Polygon -import batch_argument_parser # REMOVE THE BELOW diff --git a/workers/annotations/random_point_annotation_M1/entrypoint.py b/workers/annotations/random_point_annotation_M1/entrypoint.py index 14dbd42..d0a98d2 100644 --- a/workers/annotations/random_point_annotation_M1/entrypoint.py +++ b/workers/annotations/random_point_annotation_M1/entrypoint.py @@ -15,9 +15,7 @@ import numpy as np from skimage import filters -from skimage.feature import peak_local_max -from shapely.geometry import Polygon import utils diff --git a/workers/annotations/registration/Dockerfile b/workers/annotations/registration/Dockerfile index bf723a8..1cf37fa 100644 --- a/workers/annotations/registration/Dockerfile +++ b/workers/annotations/registration/Dockerfile @@ -12,4 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Time lapse registration" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from image-processing-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/registration/entrypoint.py b/workers/annotations/registration/entrypoint.py index 15d0f14..1869d69 100644 --- a/workers/annotations/registration/entrypoint.py +++ b/workers/annotations/registration/entrypoint.py @@ -1,10 +1,8 @@ -import base64 import argparse import json import sys import pprint -from operator import itemgetter import annotation_client.tiles as tiles import annotation_client.workers as workers @@ -15,17 +13,11 @@ import annotation_utilities.annotation_tools as annotation_tools import annotation_utilities.batch_argument_parser as batch_argument_parser -import imageio import numpy as np -from worker_client import WorkerClient -from functools import partial -from skimage import feature, filters, measure, restoration from pystackreg import StackReg -import large_image as li - def interface(image, apiUrl, token): client = workers.UPennContrastWorkerPreviewClient( @@ -144,6 +136,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps large_image off the interface path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li + tileClient = tiles.UPennContrastDataset( apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/rolling_ball/Dockerfile b/workers/annotations/rolling_ball/Dockerfile index ed77a05..fdca488 100644 --- a/workers/annotations/rolling_ball/Dockerfile +++ b/workers/annotations/rolling_ball/Dockerfile @@ -12,4 +12,4 @@ LABEL isUPennContrastWorker="" \ annotationConfigurationPanel="False" \ defaultToolName="Rolling Ball" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from image-processing-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/annotations/rolling_ball/entrypoint.py b/workers/annotations/rolling_ball/entrypoint.py index cec86b8..891fe34 100644 --- a/workers/annotations/rolling_ball/entrypoint.py +++ b/workers/annotations/rolling_ball/entrypoint.py @@ -2,24 +2,19 @@ import argparse import json import sys -import pprint from operator import itemgetter import annotation_client.tiles as tiles import annotation_client.workers as workers -from annotation_client.utils import sendProgress, sendError +from annotation_client.utils import sendProgress import imageio import numpy as np -from worker_client import WorkerClient -from functools import partial -from skimage import feature, filters, measure, restoration - -import large_image as li +from skimage import filters, restoration def preview(datasetId, apiUrl, token, params, bimage): @@ -106,6 +101,9 @@ def compute(datasetId, apiUrl, token, params): connectTo: how new annotations should be connected """ + # Lazy import: keeps large_image off the interface/preview path; only needed during compute. See todo/worker-startup-latency.md + import large_image as li + tileClient = tiles.UPennContrastDataset( apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/sam2_automatic_mask_generator/Dockerfile b/workers/annotations/sam2_automatic_mask_generator/Dockerfile index 6c3105d..c1ee737 100755 --- a/workers/annotations/sam2_automatic_mask_generator/Dockerfile +++ b/workers/annotations/sam2_automatic_mask_generator/Dockerfile @@ -68,4 +68,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 masks" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_automatic_mask_generator/Dockerfile_M1 b/workers/annotations/sam2_automatic_mask_generator/Dockerfile_M1 index 4a0c756..7cf0ef7 100755 --- a/workers/annotations/sam2_automatic_mask_generator/Dockerfile_M1 +++ b/workers/annotations/sam2_automatic_mask_generator/Dockerfile_M1 @@ -71,4 +71,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 mask generator" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_automatic_mask_generator/entrypoint.py b/workers/annotations/sam2_automatic_mask_generator/entrypoint.py index 510797f..ddfeb4b 100755 --- a/workers/annotations/sam2_automatic_mask_generator/entrypoint.py +++ b/workers/annotations/sam2_automatic_mask_generator/entrypoint.py @@ -3,7 +3,6 @@ import sys import os -from functools import partial from itertools import product import annotation_client.annotations as annotations_client @@ -18,11 +17,6 @@ from skimage.measure import find_contours from shapely.geometry import Polygon -import torch -from sam2.build_sam import build_sam2 -from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator -from sam2.sam2_image_predictor import SAM2ImagePredictor -from PIL import Image from annotation_client.utils import sendProgress @@ -72,6 +66,12 @@ def interface(image, apiUrl, token): client.setWorkerImageInterface(image, interface) def compute(datasetId, apiUrl, token, params): + # Lazy import: keeps torch/sam2 off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + from sam2.build_sam import build_sam2 + from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator + from sam2.sam2_image_predictor import SAM2ImagePredictor + annotationClient = annotations_client.UPennContrastAnnotationClient(apiUrl=apiUrl, token=token) workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) tileClient = tiles.UPennContrastDataset(apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/sam2_fewshot_segmentation/Dockerfile b/workers/annotations/sam2_fewshot_segmentation/Dockerfile index f3d057a..42c149d 100644 --- a/workers/annotations/sam2_fewshot_segmentation/Dockerfile +++ b/workers/annotations/sam2_fewshot_segmentation/Dockerfile @@ -67,4 +67,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 few-shot" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_fewshot_segmentation/Dockerfile_M1 b/workers/annotations/sam2_fewshot_segmentation/Dockerfile_M1 index 5e70586..cf7f1f4 100644 --- a/workers/annotations/sam2_fewshot_segmentation/Dockerfile_M1 +++ b/workers/annotations/sam2_fewshot_segmentation/Dockerfile_M1 @@ -70,4 +70,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 few-shot" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_fewshot_segmentation/entrypoint.py b/workers/annotations/sam2_fewshot_segmentation/entrypoint.py index 6bddb9a..3781c50 100644 --- a/workers/annotations/sam2_fewshot_segmentation/entrypoint.py +++ b/workers/annotations/sam2_fewshot_segmentation/entrypoint.py @@ -16,12 +16,6 @@ from shapely.geometry import Polygon from skimage.measure import find_contours -import torch -import torch.nn.functional as F -from sam2.build_sam import build_sam2 -from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator -from sam2.sam2_image_predictor import SAM2ImagePredictor - from annotation_client.utils import sendProgress, sendError @@ -187,6 +181,10 @@ def pool_features_with_mask(features, mask_np, feat_h, feat_w): Returns: feature_vector: tensor of shape (C,) """ + # Lazy import: keeps torch/sam2 off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + import torch.nn.functional as F + # Resize mask to feature map dimensions mask_tensor = torch.from_numpy(mask_np.astype(np.float32)).unsqueeze(0).unsqueeze(0) mask_resized = F.interpolate(mask_tensor, size=(feat_h, feat_w), mode='bilinear', align_corners=False) @@ -248,6 +246,13 @@ def annotation_to_mask(annotation, image_shape): def compute(datasetId, apiUrl, token, params): + # Lazy import: keeps torch/sam2 off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + import torch.nn.functional as F + from sam2.build_sam import build_sam2 + from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator + from sam2.sam2_image_predictor import SAM2ImagePredictor + annotationClient = annotations_client.UPennContrastAnnotationClient(apiUrl=apiUrl, token=token) workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) tileClient = tiles.UPennContrastDataset(apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/sam2_propagate/Dockerfile b/workers/annotations/sam2_propagate/Dockerfile index 09db950..e5affdc 100755 --- a/workers/annotations/sam2_propagate/Dockerfile +++ b/workers/annotations/sam2_propagate/Dockerfile @@ -68,4 +68,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 propagator" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_propagate/Dockerfile_M1 b/workers/annotations/sam2_propagate/Dockerfile_M1 index c0afeeb..f2401f5 100755 --- a/workers/annotations/sam2_propagate/Dockerfile_M1 +++ b/workers/annotations/sam2_propagate/Dockerfile_M1 @@ -71,4 +71,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 propagator" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_propagate/entrypoint.py b/workers/annotations/sam2_propagate/entrypoint.py index 2f47e69..9145d58 100755 --- a/workers/annotations/sam2_propagate/entrypoint.py +++ b/workers/annotations/sam2_propagate/entrypoint.py @@ -2,7 +2,6 @@ import json import sys import os -from functools import partial from itertools import product import uuid @@ -18,11 +17,6 @@ from skimage.measure import find_contours from shapely.geometry import Polygon -import torch -from sam2.build_sam import build_sam2 -from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator -from sam2.sam2_image_predictor import SAM2ImagePredictor -from PIL import Image from annotation_client.utils import sendProgress @@ -270,6 +264,10 @@ def compute(datasetId, apiUrl, token, params): tile: tile position (TODO: roi) ({XY, Z, Time}), connectTo: how new annotations should be connected """ + # Lazy import: keeps torch/sam2 off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + from sam2.build_sam import build_sam2 + from sam2.sam2_image_predictor import SAM2ImagePredictor annotationClient = annotations_client.UPennContrastAnnotationClient(apiUrl=apiUrl, token=token) workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) diff --git a/workers/annotations/sam2_refine/Dockerfile b/workers/annotations/sam2_refine/Dockerfile index 6a572f9..00d4cae 100755 --- a/workers/annotations/sam2_refine/Dockerfile +++ b/workers/annotations/sam2_refine/Dockerfile @@ -68,4 +68,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 Refiner" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_refine/Dockerfile_M1 b/workers/annotations/sam2_refine/Dockerfile_M1 index c0afeeb..f2401f5 100755 --- a/workers/annotations/sam2_refine/Dockerfile_M1 +++ b/workers/annotations/sam2_refine/Dockerfile_M1 @@ -71,4 +71,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 propagator" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_refine/entrypoint.py b/workers/annotations/sam2_refine/entrypoint.py index d06d000..335c1dc 100755 --- a/workers/annotations/sam2_refine/entrypoint.py +++ b/workers/annotations/sam2_refine/entrypoint.py @@ -3,7 +3,6 @@ import sys import os from collections import defaultdict -from itertools import product import annotation_client.annotations as annotations_client import annotation_client.workers as workers @@ -16,10 +15,6 @@ from shapely.geometry import Polygon from skimage.measure import find_contours -import torch -from sam2.build_sam import build_sam2 -from sam2.sam2_image_predictor import SAM2ImagePredictor - from annotation_client.utils import sendProgress @@ -178,6 +173,10 @@ def compute(datasetId, apiUrl, token, params): Refines existing annotations by using them as prompts for SAM2 segmentation. """ + # Lazy import: keeps torch/sam2 off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + from sam2.build_sam import build_sam2 + from sam2.sam2_image_predictor import SAM2ImagePredictor # Initialize clients annotationClient = annotations_client.UPennContrastAnnotationClient(apiUrl=apiUrl, token=token) diff --git a/workers/annotations/sam2_video/Dockerfile b/workers/annotations/sam2_video/Dockerfile index b8f0a8a..7458113 100755 --- a/workers/annotations/sam2_video/Dockerfile +++ b/workers/annotations/sam2_video/Dockerfile @@ -68,4 +68,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 video" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_video/Dockerfile_M1 b/workers/annotations/sam2_video/Dockerfile_M1 index a9f311f..ce02ab5 100755 --- a/workers/annotations/sam2_video/Dockerfile_M1 +++ b/workers/annotations/sam2_video/Dockerfile_M1 @@ -71,4 +71,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM2 video" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam2_video/entrypoint.py b/workers/annotations/sam2_video/entrypoint.py index 2eb0b97..cea1ca0 100755 --- a/workers/annotations/sam2_video/entrypoint.py +++ b/workers/annotations/sam2_video/entrypoint.py @@ -3,8 +3,6 @@ import sys import os -from functools import partial -from itertools import product import annotation_client.annotations as annotations_client import annotation_client.workers as workers @@ -18,11 +16,6 @@ from skimage.measure import find_contours from shapely.geometry import Polygon -import torch -from sam2.build_sam import build_sam2 -from sam2.build_sam import build_sam2_video_predictor - -from PIL import Image from annotation_client.utils import sendProgress @@ -178,6 +171,9 @@ def compute(datasetId, apiUrl, token, params): tile: tile position (TODO: roi) ({XY, Z, Time}), connectTo: how new annotations should be connected """ + # Lazy import: keeps torch/sam2 off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + from sam2.build_sam import build_sam2_video_predictor annotationClient = annotations_client.UPennContrastAnnotationClient(apiUrl=apiUrl, token=token) workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) diff --git a/workers/annotations/sam_automatic_mask_generator/Dockerfile b/workers/annotations/sam_automatic_mask_generator/Dockerfile index 3955b33..69ec4cb 100755 --- a/workers/annotations/sam_automatic_mask_generator/Dockerfile +++ b/workers/annotations/sam_automatic_mask_generator/Dockerfile @@ -58,4 +58,7 @@ LABEL isUPennContrastWorker="" \ description="Uses SAM to find all masks in the image" \ annotationShape="polygon" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam_automatic_mask_generator/Dockerfile_M1 b/workers/annotations/sam_automatic_mask_generator/Dockerfile_M1 index 183081f..745b3b2 100755 --- a/workers/annotations/sam_automatic_mask_generator/Dockerfile_M1 +++ b/workers/annotations/sam_automatic_mask_generator/Dockerfile_M1 @@ -61,4 +61,7 @@ LABEL isUPennContrastWorker="" \ description="Uses SAM to find all masks in the image" \ annotationShape="polygon" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam_automatic_mask_generator/entrypoint.py b/workers/annotations/sam_automatic_mask_generator/entrypoint.py index 6a6c864..b22300b 100755 --- a/workers/annotations/sam_automatic_mask_generator/entrypoint.py +++ b/workers/annotations/sam_automatic_mask_generator/entrypoint.py @@ -2,8 +2,6 @@ import json import sys -from functools import partial -from itertools import product import annotation_client.annotations as annotations_client import annotation_client.workers as workers @@ -14,9 +12,6 @@ from skimage.measure import find_contours from shapely.geometry import Polygon -import torch -from segment_anything import sam_model_registry, SamAutomaticMaskGenerator -from PIL import Image def interface(image, apiUrl, token): @@ -62,6 +57,10 @@ def auto_scale_image(image): def segment_image(image, model_type="vit_h", checkpoint_path="./sam_vit_h_4b8939.pth"): + # Lazy import: keeps torch + segment_anything off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + from segment_anything import sam_model_registry, SamAutomaticMaskGenerator + # image is assumed to already be an numpy array of a color image # Set up the model diff --git a/workers/annotations/sam_fewshot_segmentation/Dockerfile b/workers/annotations/sam_fewshot_segmentation/Dockerfile index 90426c5..3fb9a96 100644 --- a/workers/annotations/sam_fewshot_segmentation/Dockerfile +++ b/workers/annotations/sam_fewshot_segmentation/Dockerfile @@ -60,4 +60,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM few-shot" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam_fewshot_segmentation/Dockerfile_M1 b/workers/annotations/sam_fewshot_segmentation/Dockerfile_M1 index 0c0ab36..ae10e2f 100644 --- a/workers/annotations/sam_fewshot_segmentation/Dockerfile_M1 +++ b/workers/annotations/sam_fewshot_segmentation/Dockerfile_M1 @@ -61,4 +61,7 @@ LABEL isUPennContrastWorker="" \ annotationShape="polygon" \ defaultToolName="SAM few-shot" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/sam_fewshot_segmentation/entrypoint.py b/workers/annotations/sam_fewshot_segmentation/entrypoint.py index aabaf14..b1e6895 100644 --- a/workers/annotations/sam_fewshot_segmentation/entrypoint.py +++ b/workers/annotations/sam_fewshot_segmentation/entrypoint.py @@ -1,7 +1,6 @@ import argparse import json import sys -import os from itertools import product @@ -16,10 +15,6 @@ from shapely.geometry import Polygon from skimage.measure import find_contours -import torch -import torch.nn.functional as F -from segment_anything import sam_model_registry, SamAutomaticMaskGenerator, SamPredictor - from annotation_client.utils import sendProgress, sendError @@ -44,8 +39,6 @@ def _safe_batched_nms(boxes, scores, idxs, iou_threshold): except Exception: pass -_patch_torchvision_nms() - def interface(image, apiUrl, token): client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) @@ -206,6 +199,10 @@ def pool_features_with_mask(features, mask_np, feat_h, feat_w): Returns: feature_vector: tensor of shape (C,) """ + # Lazy import: keeps torch off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + import torch.nn.functional as F + # Resize mask to feature map dimensions mask_tensor = torch.from_numpy(mask_np.astype(np.float32)).unsqueeze(0).unsqueeze(0) mask_resized = F.interpolate(mask_tensor, size=(feat_h, feat_w), mode='bilinear', align_corners=False) @@ -267,6 +264,16 @@ def annotation_to_mask(annotation, image_shape): def compute(datasetId, apiUrl, token, params): + # Lazy import: keeps torch off the interface/startup path (~seconds). See todo/worker-startup-latency.md + import torch + import torch.nn.functional as F + # Lazy import: keeps segment_anything off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from segment_anything import sam_model_registry, SamAutomaticMaskGenerator, SamPredictor + + # Apply the torchvision NMS compatibility patch lazily (it imports torchvision + + # segment_anything); previously this ran at module load. See todo/worker-startup-latency.md + _patch_torchvision_nms() + annotationClient = annotations_client.UPennContrastAnnotationClient(apiUrl=apiUrl, token=token) workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) tileClient = tiles.UPennContrastDataset(apiUrl=apiUrl, token=token, datasetId=datasetId) diff --git a/workers/annotations/stardist/Dockerfile b/workers/annotations/stardist/Dockerfile index bf44164..4c9c096 100755 --- a/workers/annotations/stardist/Dockerfile +++ b/workers/annotations/stardist/Dockerfile @@ -65,4 +65,7 @@ LABEL isUPennContrastWorker=True \ description="Uses Stardist to find cells and nuclei" \ defaultToolName="Stardist" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/annotations/stardist/entrypoint.py b/workers/annotations/stardist/entrypoint.py index 13f1e71..0e13393 100755 --- a/workers/annotations/stardist/entrypoint.py +++ b/workers/annotations/stardist/entrypoint.py @@ -2,7 +2,6 @@ import json import sys import timeit -from functools import partial import annotation_client.workers as workers import annotation_client.tiles as tiles @@ -10,11 +9,8 @@ from annotation_client.utils import sendProgress import numpy as np -from stardist.models import StarDist2D from shapely.geometry import Polygon from annotation_utilities.annotation_tools import geometry_to_polygon_coords -from rasterio import features -import rasterio.transform def interface(image, apiUrl, token): @@ -82,6 +78,11 @@ def run_stardist(image, model, prob_thresh, nms_thresh): def labels_to_polygons(labels): + # Lazy import: keeps rasterio off the interface path; only needed during compute. See todo/worker-startup-latency.md + from rasterio import features + # Lazy import: keeps rasterio off the interface path; only needed during compute. See todo/worker-startup-latency.md + import rasterio.transform + polygons = [] # Create a default transform default_transform = rasterio.transform.from_bounds( @@ -106,6 +107,9 @@ def labels_to_polygons(labels): def compute(datasetId, apiUrl, token, params): + # Lazy import: keeps stardist off the interface/startup path (~seconds). See todo/worker-startup-latency.md + from stardist.models import StarDist2D + start_time = timeit.default_timer() workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) diff --git a/workers/annotations/test_multiple_annotation/entrypoint.py b/workers/annotations/test_multiple_annotation/entrypoint.py index e59dbf9..2501ad4 100644 --- a/workers/annotations/test_multiple_annotation/entrypoint.py +++ b/workers/annotations/test_multiple_annotation/entrypoint.py @@ -15,9 +15,7 @@ import numpy as np from skimage import filters -from skimage.feature import peak_local_max -from shapely.geometry import Polygon import utils diff --git a/workers/annotations/test_multiple_annotation_M1/entrypoint.py b/workers/annotations/test_multiple_annotation_M1/entrypoint.py index e59dbf9..2501ad4 100644 --- a/workers/annotations/test_multiple_annotation_M1/entrypoint.py +++ b/workers/annotations/test_multiple_annotation_M1/entrypoint.py @@ -15,9 +15,7 @@ import numpy as np from skimage import filters -from skimage.feature import peak_local_max -from shapely.geometry import Polygon import utils diff --git a/workers/base_docker_images/Dockerfile.image_processing_worker_base b/workers/base_docker_images/Dockerfile.image_processing_worker_base index 062bdb1..8e57b2e 100644 --- a/workers/base_docker_images/Dockerfile.image_processing_worker_base +++ b/workers/base_docker_images/Dockerfile.image_processing_worker_base @@ -10,12 +10,10 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata build-essential \ wget \ python3 \ - r-base \ libffi-dev \ libssl-dev \ libjpeg-dev \ zlib1g-dev \ - r-base \ git \ libpython3-dev \ libtiff-dev \ @@ -81,9 +79,24 @@ RUN pip install . RUN pip install histomicstk --find-links https://girder.github.io/large_image_wheels RUN pip install large-image-source-tiff large-image-source-zarr large-image-source-gdal large-image-converter large-image --find-links https://girder.github.io/large_image_wheels +# Slim the image: drop conda's package cache/tarballs +RUN conda clean --all --yes + # Reset WORKDIR for worker entrypoint WORKDIR / +# Shared fast-activation entrypoint. Replaces per-worker +# `conda run --no-capture-output -n worker python /entrypoint.py`, which spawns a +# second Python process (the conda CLI) and adds ~0.8-1.0s of startup latency to +# every job. run_worker.sh activates the env in-process (PATH + activate.d hooks, +# preserving GDAL/PROJ vars) and execs the env python. Workers inherit this +# ENTRYPOINT and only need to COPY their /entrypoint.py. +# See todo/worker-startup-latency.md. +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] + # Base image setup is complete. -# Image processing worker images will build FROM this, COPY their specific environment.yml, -# run conda env update, COPY their entrypoint.py, and set ENTRYPOINT/LABELs. \ No newline at end of file +# Image processing worker images will build FROM this, COPY their specific +# environment.yml, run conda env update, and COPY their entrypoint.py. The +# ENTRYPOINT above is inherited; workers only need to set their own LABELs. \ No newline at end of file diff --git a/workers/base_docker_images/Dockerfile.worker_base b/workers/base_docker_images/Dockerfile.worker_base index bcaa1f8..d9efcaf 100644 --- a/workers/base_docker_images/Dockerfile.worker_base +++ b/workers/base_docker_images/Dockerfile.worker_base @@ -10,12 +10,10 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata build-essential \ wget \ python3 \ - r-base \ libffi-dev \ libssl-dev \ libjpeg-dev \ zlib1g-dev \ - r-base \ git \ libpython3-dev && \ apt-get clean && rm -rf /var/lib/apt/lists/* @@ -67,9 +65,31 @@ WORKDIR /NimbusImage RUN bash -c "source $(conda info --base)/etc/profile.d/conda.sh && conda activate worker && pip install -r devops/girder/annotation_client/requirements.txt" RUN bash -c "source $(conda info --base)/etc/profile.d/conda.sh && conda activate worker && pip install -e devops/girder/annotation_client/" +# Install worker client (small pure-python helper; mirrors image-processing-base +# so CPU workers can build FROM this base without re-installing it themselves). +COPY ./worker_client /worker_client +WORKDIR /worker_client +RUN bash -c "source $(conda info --base)/etc/profile.d/conda.sh && conda activate worker && pip install ." + +# Slim the image: drop conda's package cache/tarballs +RUN bash -c "source $(conda info --base)/etc/profile.d/conda.sh && conda clean --all --yes" + # Reset WORKDIR for worker entrypoint WORKDIR / +# Shared fast-activation entrypoint. Replaces per-worker +# `conda run --no-capture-output -n worker python /entrypoint.py`, which spawns a +# second Python process (the conda CLI) and adds ~0.8-1.0s of startup latency to +# every job. run_worker.sh activates the env in-process (PATH + activate.d hooks, +# preserving GDAL/PROJ vars) and execs the env python. Workers inherit this +# ENTRYPOINT and only need to COPY their /entrypoint.py. +# See todo/worker-startup-latency.md. +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] + # Base image setup is complete. # Worker images will build FROM this, COPY their specific environment.yml, -# run conda env update, COPY their entrypoint.py, and set ENTRYPOINT/LABELs. \ No newline at end of file +# run conda env update, and COPY their entrypoint.py. The ENTRYPOINT above is +# inherited; workers only need to set their own LABELs (and may override +# ENTRYPOINT if they truly need to). \ No newline at end of file diff --git a/workers/base_docker_images/run_worker.sh b/workers/base_docker_images/run_worker.sh new file mode 100644 index 0000000..bbacc3b --- /dev/null +++ b/workers/base_docker_images/run_worker.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Fast in-process activation of the "worker" conda env, used as the Docker +# ENTRYPOINT in place of `conda run --no-capture-output -n worker`. +# +# Why: `conda run` spawns a *second* Python process (the conda CLI itself) on +# every invocation just to activate the env and exec the target interpreter. +# Because NimbusImage launches a fresh container per job, that wrapper adds +# ~0.8-1.0s of pure startup latency to every single run. This script activates +# the env directly instead: it puts the env on PATH and sources the env's +# activate.d hooks -- which export GDAL_DATA / PROJ_DATA / GDAL_DRIVER_PATH that +# rasterio / large_image rely on at runtime -- then execs the env's python. +# This preserves conda's activation side effects while skipping the extra +# interpreter. See todo/worker-startup-latency.md for measurements and rationale. +# +# Usage in a worker Dockerfile (build context is the repo root): +# COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +# RUN chmod +x /usr/local/bin/run_worker.sh +# ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] +# Args appended by `docker run` (e.g. --apiUrl ... --request ...) are forwarded +# to the entrypoint script unchanged. + +# Locate the worker env: miniforge on amd64 (production), miniconda on arm64 (Mac dev). +CONDA_PREFIX="$(ls -d /root/miniforge3/envs/worker /root/miniconda3/envs/worker 2>/dev/null | head -n1)" +export CONDA_PREFIX +export CONDA_DEFAULT_ENV=worker +export PATH="$CONDA_PREFIX/bin:$PATH" + +# Source the env's activation hooks (GDAL/PROJ/etc.), exactly as conda would on activate. +if [ -d "$CONDA_PREFIX/etc/conda/activate.d" ]; then + for _hook in "$CONDA_PREFIX"/etc/conda/activate.d/*.sh; do + [ -e "$_hook" ] && . "$_hook" + done +fi + +exec "$CONDA_PREFIX/bin/python" "$@" diff --git a/workers/properties/blobs/blob_annulus_intensity_percentile_worker/entrypoint.py b/workers/properties/blobs/blob_annulus_intensity_percentile_worker/entrypoint.py index aeaf6bf..231a53e 100755 --- a/workers/properties/blobs/blob_annulus_intensity_percentile_worker/entrypoint.py +++ b/workers/properties/blobs/blob_annulus_intensity_percentile_worker/entrypoint.py @@ -12,7 +12,6 @@ import numpy as np from skimage import draw -from skimage import morphology from collections import defaultdict from shapely.geometry import Polygon diff --git a/workers/properties/blobs/blob_annulus_intensity_worker/Dockerfile b/workers/properties/blobs/blob_annulus_intensity_worker/Dockerfile index 9f42d79..7393149 100755 --- a/workers/properties/blobs/blob_annulus_intensity_worker/Dockerfile +++ b/workers/properties/blobs/blob_annulus_intensity_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Intensity" \ description="Compute intensity measurements (mean, median, etc.) for an annular region around blobs" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/blobs/blob_annulus_intensity_worker/entrypoint.py b/workers/properties/blobs/blob_annulus_intensity_worker/entrypoint.py index 929696b..6a21f80 100755 --- a/workers/properties/blobs/blob_annulus_intensity_worker/entrypoint.py +++ b/workers/properties/blobs/blob_annulus_intensity_worker/entrypoint.py @@ -4,7 +4,7 @@ import timeit import annotation_client.workers as workers -from annotation_client.utils import sendProgress, sendWarning, sendError +from annotation_client.utils import sendProgress, sendWarning import annotation_client.tiles as tiles import annotation_utilities.annotation_tools as annotation_tools diff --git a/workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile b/workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile index 6960ef1..c544276 100755 --- a/workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile +++ b/workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile @@ -1,54 +1,6 @@ -FROM ubuntu:jammy as base -LABEL isUPennContrastWorker=True - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -# The below is for the x86 and should be changed for other architectures -ENV PATH="/root/miniforge3/bin:$PATH" -ARG PATH="/root/miniforge3/bin:$PATH" - -RUN wget \ - https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh \ - && mkdir /root/.conda \ - && bash Miniforge3-Linux-x86_64.sh -b \ - && rm -f Miniforge3-Linux-x86_64.sh -# END x86 specific - -FROM base as build - -# Accept Anaconda Terms of Service for defaults channel -ENV CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes - -COPY ./workers/properties/blobs/blob_colony_two_color_intensity_worker/environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -# Install the annotation utilities -COPY ./annotation_utilities /annotation_utilities -WORKDIR /annotation_utilities -RUN pip install . - -WORKDIR / -RUN git clone https://github.com/Kitware/UPennContrast/ -WORKDIR /UPennContrast - -RUN pip install -r devops/girder/annotation_client/requirements.txt -RUN pip install -e devops/girder/annotation_client/ +FROM nimbusimage/worker-base:latest +# Copy the entrypoint script COPY ./workers/properties/blobs/blob_colony_two_color_intensity_worker/entrypoint.py / LABEL isUPennContrastWorker="" \ @@ -58,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Intensity" \ description="Compute intensity across two channels for blobs; useful for colony intensity measurements" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) diff --git a/workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile_M1 b/workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile_M1 deleted file mode 100755 index 8ee4713..0000000 --- a/workers/properties/blobs/blob_colony_two_color_intensity_worker/Dockerfile_M1 +++ /dev/null @@ -1,61 +0,0 @@ -FROM ubuntu:jammy as base -LABEL isUPennContrastWorker=True - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -# The below is for the M1 Macs and should be changed for other architectures -ENV PATH="/root/miniconda3/bin:$PATH" -ARG PATH="/root/miniconda3/bin:$PATH" - -RUN wget \ - https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-aarch64.sh \ - && mkdir /root/.conda \ - && bash Miniconda3-latest-Linux-aarch64.sh -b \ - && rm -f Miniconda3-latest-Linux-aarch64.sh -# END M1 Mac specific - -FROM base as build - -# Accept Anaconda Terms of Service for defaults channel -ENV CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes - -COPY ./workers/properties/blobs/blob_colony_two_color_intensity_worker/environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -# Install the annotation utilities -COPY ./annotation_utilities /annotation_utilities -WORKDIR /annotation_utilities -RUN pip install . - -WORKDIR / -RUN git clone https://github.com/Kitware/UPennContrast/ -WORKDIR /UPennContrast - -RUN pip install -r devops/girder/annotation_client/requirements.txt -RUN pip install -e devops/girder/annotation_client/ - -COPY ./workers/properties/blobs/blob_colony_two_color_intensity_worker/entrypoint.py / - -LABEL isUPennContrastWorker="" \ - isPropertyWorker="" \ - annotationShape="polygon" \ - interfaceName="Colony two color intensity" \ - interfaceCategory="Intensity" \ - description="Compute intensity across two channels for blobs; useful for colony intensity measurements" - -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/properties/blobs/blob_intensity_worker/Dockerfile b/workers/properties/blobs/blob_intensity_worker/Dockerfile index 4e19467..a529758 100755 --- a/workers/properties/blobs/blob_intensity_worker/Dockerfile +++ b/workers/properties/blobs/blob_intensity_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Intensity" \ description="Compute intensity measurements (mean, median, etc.) for blobs" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/blobs/blob_intensity_worker/entrypoint.py b/workers/properties/blobs/blob_intensity_worker/entrypoint.py index ce5dc96..3c88dfc 100755 --- a/workers/properties/blobs/blob_intensity_worker/entrypoint.py +++ b/workers/properties/blobs/blob_intensity_worker/entrypoint.py @@ -4,7 +4,7 @@ import timeit import annotation_client.workers as workers -from annotation_client.utils import sendProgress, sendWarning, sendError +from annotation_client.utils import sendProgress, sendWarning import annotation_client.tiles as tiles import annotation_utilities.annotation_tools as annotation_tools diff --git a/workers/properties/blobs/blob_metrics_worker/Dockerfile b/workers/properties/blobs/blob_metrics_worker/Dockerfile index 01b73ea..fb1b968 100755 --- a/workers/properties/blobs/blob_metrics_worker/Dockerfile +++ b/workers/properties/blobs/blob_metrics_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Morphology" \ description="Compute area, perimeter, and other metrics for blobs" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/blobs/blob_overlap_worker/Dockerfile b/workers/properties/blobs/blob_overlap_worker/Dockerfile index a815b57..73b1a2f 100755 --- a/workers/properties/blobs/blob_overlap_worker/Dockerfile +++ b/workers/properties/blobs/blob_overlap_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Relational" \ description="Compute overlap between blobs" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/blobs/blob_overlap_worker/entrypoint.py b/workers/properties/blobs/blob_overlap_worker/entrypoint.py index fd4dce6..ea730de 100755 --- a/workers/properties/blobs/blob_overlap_worker/entrypoint.py +++ b/workers/properties/blobs/blob_overlap_worker/entrypoint.py @@ -8,8 +8,6 @@ import annotation_utilities.annotation_tools as annotation_tools from shapely.geometry import Polygon, Point -import numpy as np -import geopandas as gpd def interface(image, apiUrl, token): @@ -39,6 +37,9 @@ def interface(image, apiUrl, token): def extract_spatial_annotation_data(obj_list): + # Lazy import: keeps geopandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import geopandas as gpd + data = [] for obj in obj_list: shape = obj['shape'] @@ -69,6 +70,9 @@ def extract_spatial_annotation_data(obj_list): def compute(datasetId, apiUrl, token, params): + # Lazy import: keeps geopandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import geopandas as gpd + workerClient = workers.UPennContrastWorkerClient( datasetId, apiUrl, token, params) diff --git a/workers/properties/blobs/blob_point_count_3D_projection_worker/entrypoint.py b/workers/properties/blobs/blob_point_count_3D_projection_worker/entrypoint.py index d3a96c1..d73fa89 100755 --- a/workers/properties/blobs/blob_point_count_3D_projection_worker/entrypoint.py +++ b/workers/properties/blobs/blob_point_count_3D_projection_worker/entrypoint.py @@ -5,14 +5,12 @@ import annotation_client.workers as workers from annotation_client.utils import sendProgress -from shapely.geometry import Point, Polygon +from shapely.geometry import Polygon -from annotation_utilities.point_in_polygon import point_in_polygon from annotation_utilities import annotation_tools from rtree import index -import numpy as np def interface(image, apiUrl, token): client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) diff --git a/workers/properties/blobs/blob_point_count_worker/Dockerfile b/workers/properties/blobs/blob_point_count_worker/Dockerfile index a5f33b4..fa33e87 100755 --- a/workers/properties/blobs/blob_point_count_worker/Dockerfile +++ b/workers/properties/blobs/blob_point_count_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Quantification" \ description="Count the number of points that fall within a blob/polygon; can work in 2D or 3D" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/blobs/blob_point_count_worker/entrypoint.py b/workers/properties/blobs/blob_point_count_worker/entrypoint.py index ba56044..0a2473c 100755 --- a/workers/properties/blobs/blob_point_count_worker/entrypoint.py +++ b/workers/properties/blobs/blob_point_count_worker/entrypoint.py @@ -5,14 +5,12 @@ import annotation_client.workers as workers from annotation_client.utils import sendProgress -from shapely.geometry import Point, Polygon +from shapely.geometry import Polygon -from annotation_utilities.point_in_polygon import point_in_polygon from annotation_utilities import annotation_tools from rtree import index -import numpy as np def interface(image, apiUrl, token): client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) diff --git a/workers/properties/blobs/blob_random_forest_classifier/Dockerfile b/workers/properties/blobs/blob_random_forest_classifier/Dockerfile index 6a2c243..057d7a7 100755 --- a/workers/properties/blobs/blob_random_forest_classifier/Dockerfile +++ b/workers/properties/blobs/blob_random_forest_classifier/Dockerfile @@ -58,4 +58,7 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Classification" \ description="Classify blobs using a Random Forest Classifier" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/properties/blobs/blob_random_forest_classifier/Dockerfile_M1 b/workers/properties/blobs/blob_random_forest_classifier/Dockerfile_M1 index 98ed09c..5734044 100755 --- a/workers/properties/blobs/blob_random_forest_classifier/Dockerfile_M1 +++ b/workers/properties/blobs/blob_random_forest_classifier/Dockerfile_M1 @@ -58,4 +58,7 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Classification" \ description="Classify blobs using a Random Forest Classifier" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# Fast env activation in place of `conda run` (see todo/worker-startup-latency.md) +COPY ./workers/base_docker_images/run_worker.sh /usr/local/bin/run_worker.sh +RUN chmod +x /usr/local/bin/run_worker.sh +ENTRYPOINT ["/usr/local/bin/run_worker.sh", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/properties/blobs/blob_random_forest_classifier/entrypoint.py b/workers/properties/blobs/blob_random_forest_classifier/entrypoint.py index 89dab42..1fdad79 100755 --- a/workers/properties/blobs/blob_random_forest_classifier/entrypoint.py +++ b/workers/properties/blobs/blob_random_forest_classifier/entrypoint.py @@ -2,27 +2,17 @@ import json import sys import timeit -import pprint import annotation_client.workers as workers from annotation_client.utils import sendProgress, sendWarning, sendError import annotation_client.tiles as tiles import annotation_utilities.annotation_tools as annotation_tools -import annotation_utilities.batch_argument_parser as batch_argument_parser from annotation_utilities.progress import update_progress import numpy as np from skimage import draw from collections import defaultdict from shapely.geometry import Polygon -import pandas as pd - -# Import mahotas for texture features -import mahotas as mh - -from sklearn.ensemble import RandomForestClassifier -from sklearn.model_selection import train_test_split -from sklearn.metrics import classification_report def interface(image, apiUrl, token): @@ -81,6 +71,9 @@ def compute_texture_features(image_patch, levels=8): Returns: Dictionary of texture features """ + # Lazy import: keeps mahotas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import mahotas as mh + features = {} # Ensure the image is properly scaled for texture analysis @@ -146,6 +139,12 @@ def compute(datasetId, apiUrl, token, params): layer: Which specific layer should be used for intensity calculations tags: A list of annotation tags, used when counting for instance the number of connections to specific tagged annotations """ + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + # Lazy import: keeps scikit-learn off the interface path; only needed during compute. See todo/worker-startup-latency.md + from sklearn.ensemble import RandomForestClassifier + from sklearn.model_selection import train_test_split + from sklearn.metrics import classification_report workerClient = workers.UPennContrastWorkerClient( datasetId, apiUrl, token, params) diff --git a/workers/properties/connections/children_count_worker/Dockerfile b/workers/properties/connections/children_count_worker/Dockerfile index 6886763..37da822 100755 --- a/workers/properties/connections/children_count_worker/Dockerfile +++ b/workers/properties/connections/children_count_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Quantification" \ description="Count the number of children objects that are connected to a parent polygon" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/connections/children_count_worker/entrypoint.py b/workers/properties/connections/children_count_worker/entrypoint.py index ceadf30..397f9cd 100644 --- a/workers/properties/connections/children_count_worker/entrypoint.py +++ b/workers/properties/connections/children_count_worker/entrypoint.py @@ -6,8 +6,6 @@ import annotation_client.workers as workers from annotation_client.utils import sendProgress -import pandas as pd -import numpy as np def interface(image, apiUrl, token): @@ -35,6 +33,9 @@ def interface(image, apiUrl, token): def compute(datasetId, apiUrl, token, params): + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + propertyId = params.get('id', 'unknown_property') workerInterface = params['workerInterface'] diff --git a/workers/properties/connections/parent_child_worker/Dockerfile b/workers/properties/connections/parent_child_worker/Dockerfile index 3d3b653..4c7ff38 100755 --- a/workers/properties/connections/parent_child_worker/Dockerfile +++ b/workers/properties/connections/parent_child_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Connections" \ description="Creates a set of identifiers that indicate the parent-child relationships between polygons" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/connections/parent_child_worker/entrypoint.py b/workers/properties/connections/parent_child_worker/entrypoint.py index 97b232d..0a3d3b8 100644 --- a/workers/properties/connections/parent_child_worker/entrypoint.py +++ b/workers/properties/connections/parent_child_worker/entrypoint.py @@ -1,7 +1,6 @@ import argparse import json import sys -from collections import defaultdict import annotation_client.annotations as annotations from annotation_client.utils import sendProgress diff --git a/workers/properties/lines/line_length_worker/entrypoint.py b/workers/properties/lines/line_length_worker/entrypoint.py index 1eba5c5..66ef9e6 100755 --- a/workers/properties/lines/line_length_worker/entrypoint.py +++ b/workers/properties/lines/line_length_worker/entrypoint.py @@ -6,8 +6,6 @@ import annotation_tools -import cv2 as cv -import numpy as np import math def calculate_distance(p1, p2): diff --git a/workers/properties/lines/line_scan_worker/Dockerfile b/workers/properties/lines/line_scan_worker/Dockerfile index a181bc6..bf446eb 100755 --- a/workers/properties/lines/line_scan_worker/Dockerfile +++ b/workers/properties/lines/line_scan_worker/Dockerfile @@ -1,46 +1,6 @@ -FROM ubuntu:jammy as base -LABEL isUPennContrastWorker=True - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -ENV PATH="/root/miniforge3/bin:$PATH" -ARG PATH="/root/miniforge3/bin:$PATH" - -RUN wget \ - https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh \ - && mkdir /root/.conda \ - && bash Miniforge3-Linux-x86_64.sh -b \ - && rm -f Miniforge3-Linux-x86_64.sh - -FROM base as build - -# Accept Anaconda Terms of Service for defaults channel -ENV CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes - -COPY ./workers/properties/lines/line_scan_worker/environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -RUN git clone https://github.com/Kitware/UPennContrast/ -WORKDIR /UPennContrast - -RUN pip install -r devops/girder/annotation_client/requirements.txt -RUN pip install -e devops/girder/annotation_client/ +FROM nimbusimage/worker-base:latest +# Copy the entrypoint script COPY ./workers/properties/lines/line_scan_worker/entrypoint.py / LABEL isUPennContrastWorker="" \ @@ -50,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Intensity" \ description="Compute the intensity along a line and puts the results in a CSV file" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) diff --git a/workers/properties/lines/line_scan_worker/Dockerfile_M1 b/workers/properties/lines/line_scan_worker/Dockerfile_M1 deleted file mode 100755 index 9b9cdcb..0000000 --- a/workers/properties/lines/line_scan_worker/Dockerfile_M1 +++ /dev/null @@ -1,53 +0,0 @@ -FROM ubuntu:jammy as base -LABEL isUPennContrastWorker=True - -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -qy tzdata && \ - apt-get install -qy software-properties-common python3-software-properties && \ - apt-get update && apt-get install -qy \ - build-essential \ - wget \ - python3 \ - r-base \ - libffi-dev \ - libssl-dev \ - libjpeg-dev \ - zlib1g-dev \ - r-base \ - git \ - libpython3-dev && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -ENV PATH="/root/miniconda3/bin:$PATH" -ARG PATH="/root/miniconda3/bin:$PATH" - -RUN wget \ - https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-aarch64.sh \ - && mkdir /root/.conda \ - && bash Miniconda3-latest-Linux-aarch64.sh -b \ - && rm -f Miniconda3-latest-Linux-aarch64.sh - -FROM base as build - -# Accept Anaconda Terms of Service for defaults channel -ENV CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes - -COPY ./workers/properties/lines/line_scan_worker/environment.yml / -RUN conda env create --file /environment.yml -SHELL ["conda", "run", "-n", "worker", "/bin/bash", "-c"] - -RUN git clone https://github.com/Kitware/UPennContrast/ -WORKDIR /UPennContrast - -RUN pip install -r devops/girder/annotation_client/requirements.txt -RUN pip install -e devops/girder/annotation_client/ - -COPY ./workers/properties/lines/line_scan_worker/entrypoint.py / - -LABEL isUPennContrastWorker="" \ - isPropertyWorker="" \ - annotationShape="line" \ - interfaceName="Line scan CSV" \ - interfaceCategory="Intensity" \ - description="Compute the intensity along a line and puts the results in a CSV file" - -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file diff --git a/workers/properties/lines/line_scan_worker/entrypoint.py b/workers/properties/lines/line_scan_worker/entrypoint.py index 9b5d59f..53e520d 100755 --- a/workers/properties/lines/line_scan_worker/entrypoint.py +++ b/workers/properties/lines/line_scan_worker/entrypoint.py @@ -2,7 +2,6 @@ import json import sys import numpy as np -import pandas as pd import io import annotation_client.workers as workers @@ -10,8 +9,6 @@ import annotation_client.tiles as tiles from annotation_client.utils import sendProgress -from scipy.ndimage import map_coordinates - def interface(image, apiUrl, token): client = workers.UPennContrastWorkerPreviewClient(apiUrl=apiUrl, token=token) @@ -42,6 +39,11 @@ def interface(image, apiUrl, token): client.setWorkerImageInterface(image, interface) def compute(datasetId, apiUrl, token, params): + # Lazy import: keeps pandas off the interface path; only needed during compute. See todo/worker-startup-latency.md + import pandas as pd + # Lazy import: keeps scipy off the interface path; only needed during compute. See todo/worker-startup-latency.md + from scipy.ndimage import map_coordinates + workerClient = workers.UPennContrastWorkerClient(datasetId, apiUrl, token, params) annotationClient = annotations.UPennContrastAnnotationClient(apiUrl=apiUrl, token=token) datasetClient = tiles.UPennContrastDataset(apiUrl=apiUrl, token=token, datasetId=datasetId) @@ -166,26 +168,27 @@ def compute(datasetId, apiUrl, token, params): sendProgress(1.0, 'Finished', 'Line scan CSV creation completed') if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Line scan CSV worker') - parser.add_argument('--datasetId', type=str, required=False, action='store') + # Define the command-line interface for the entry point + parser = argparse.ArgumentParser( + description='Compute the intensity along a line and save it to a CSV file') + + parser.add_argument('--datasetId', type=str, + required=False, action='store') parser.add_argument('--apiUrl', type=str, required=True, action='store') parser.add_argument('--token', type=str, required=True, action='store') parser.add_argument('--request', type=str, required=True, action='store') - parser.add_argument('--parameters', type=str, required=True, action='store') + parser.add_argument('--parameters', type=str, + required=True, action='store') args = parser.parse_args(sys.argv[1:]) params = json.loads(args.parameters) + datasetId = args.datasetId apiUrl = args.apiUrl token = args.token - if args.request == 'compute': - if not args.datasetId: - print("Error: datasetId is required for compute request") - sys.exit(1) - compute(args.datasetId, apiUrl, token, params) - elif args.request == 'interface': - interface(params['image'], apiUrl, token) - else: - print(f"Error: Unknown request type '{args.request}'") - sys.exit(1) + match args.request: + case 'compute': + compute(datasetId, apiUrl, token, params) + case 'interface': + interface(params['image'], apiUrl, token) diff --git a/workers/properties/lines/test_file_creation_worker/entrypoint.py b/workers/properties/lines/test_file_creation_worker/entrypoint.py index 8547797..c3d581f 100755 --- a/workers/properties/lines/test_file_creation_worker/entrypoint.py +++ b/workers/properties/lines/test_file_creation_worker/entrypoint.py @@ -3,7 +3,6 @@ import sys import pandas as pd import io -import girder_client import annotation_client.workers as workers import annotation_client.annotations as annotations diff --git a/workers/properties/points/point_circle_intensity_worker/Dockerfile b/workers/properties/points/point_circle_intensity_worker/Dockerfile index 0e9b77d..207e1ac 100755 --- a/workers/properties/points/point_circle_intensity_worker/Dockerfile +++ b/workers/properties/points/point_circle_intensity_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Intensity" \ description="Computes a variety of intensity metrics around a point; radius 1 for pixel precision" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/points/point_circle_intensity_worker/entrypoint.py b/workers/properties/points/point_circle_intensity_worker/entrypoint.py index c29b016..761b4a1 100644 --- a/workers/properties/points/point_circle_intensity_worker/entrypoint.py +++ b/workers/properties/points/point_circle_intensity_worker/entrypoint.py @@ -1,7 +1,6 @@ import argparse import json import sys -import time import annotation_client.workers as workers import annotation_client.tiles as tiles diff --git a/workers/properties/points/point_metrics_worker/Dockerfile b/workers/properties/points/point_metrics_worker/Dockerfile index aadaf04..957eeb4 100755 --- a/workers/properties/points/point_metrics_worker/Dockerfile +++ b/workers/properties/points/point_metrics_worker/Dockerfile @@ -10,4 +10,4 @@ LABEL isUPennContrastWorker="" \ interfaceCategory="Morphology" \ description="Computes a variety of metrics for points, like X and Y coordinates" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python3", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/points/point_to_nearest_blob_distance/Dockerfile b/workers/properties/points/point_to_nearest_blob_distance/Dockerfile index 97552d0..f0595d3 100755 --- a/workers/properties/points/point_to_nearest_blob_distance/Dockerfile +++ b/workers/properties/points/point_to_nearest_blob_distance/Dockerfile @@ -9,4 +9,4 @@ LABEL isUPennContrastWorker="" \ interfaceName="Distance to nearest blob" \ interfaceCategory="Distance" -ENTRYPOINT ["conda", "run", "--no-capture-output", "-n", "worker", "python", "/entrypoint.py"] \ No newline at end of file +# ENTRYPOINT inherited from worker-base (run_worker.sh /entrypoint.py — see todo/worker-startup-latency.md) \ No newline at end of file diff --git a/workers/properties/points/point_to_nearest_blob_distance/entrypoint.py b/workers/properties/points/point_to_nearest_blob_distance/entrypoint.py index e1a2107..4b2b913 100755 --- a/workers/properties/points/point_to_nearest_blob_distance/entrypoint.py +++ b/workers/properties/points/point_to_nearest_blob_distance/entrypoint.py @@ -1,8 +1,6 @@ import argparse import json import sys -import numpy as np -from scipy.spatial import distance from shapely.geometry import Point, Polygon import annotation_client.workers as workers diff --git a/workers/properties/points/point_to_nearest_connected_point_distance/entrypoint.py b/workers/properties/points/point_to_nearest_connected_point_distance/entrypoint.py index e9b6ac5..79edf47 100755 --- a/workers/properties/points/point_to_nearest_connected_point_distance/entrypoint.py +++ b/workers/properties/points/point_to_nearest_connected_point_distance/entrypoint.py @@ -6,8 +6,6 @@ import annotation_client.annotations as annotations import annotation_tools -import cv2 as cv -import numpy as np import math def calculate_distance(point1, point2): diff --git a/workers/properties/points/point_to_nearest_point_distance/entrypoint.py b/workers/properties/points/point_to_nearest_point_distance/entrypoint.py index 6b40ac2..edba703 100755 --- a/workers/properties/points/point_to_nearest_point_distance/entrypoint.py +++ b/workers/properties/points/point_to_nearest_point_distance/entrypoint.py @@ -5,8 +5,6 @@ import annotation_client.workers as workers import annotation_tools -import cv2 as cv -import numpy as np import math def calculate_distance(point1, point2):