-
Notifications
You must be signed in to change notification settings - Fork 2
Guard channelCheckboxes parsing against malformed (non-dict) values #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
8376c81
7ef96f1
c44f190
7e17e24
fc97d26
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Python build/test artifacts | ||
| __pycache__/ | ||
| *.py[cod] | ||
| *$py.class | ||
| .pytest_cache/ | ||
| .coverage | ||
| *.egg-info/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| package_root = Path(__file__).resolve().parents[1] | ||
| sys.path.insert(0, str(package_root)) | ||
|
|
||
| from annotation_utilities.annotation_tools import get_selected_channels | ||
|
|
||
|
|
||
| def test_dict_returns_sorted_selected_channels(): | ||
| assert get_selected_channels({'0': True, '1': False}) == [0] | ||
| assert get_selected_channels({'2': True, '0': True, '1': False}) == [0, 2] | ||
|
|
||
|
|
||
| def test_empty_dict_returns_empty_list(): | ||
| assert get_selected_channels({}) == [] | ||
|
|
||
|
|
||
| def test_none_returns_empty_list(): | ||
| assert get_selected_channels(None) == [] | ||
|
|
||
|
|
||
| def test_all_false_dict_returns_empty_list(): | ||
| assert get_selected_channels({'0': False, '1': False}) == [] | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("bad_value", [[0], [], [0, 1], (0,), '0', 0, True]) | ||
| def test_non_dict_values_raise_value_error(bad_value): | ||
| # The list form ([0]) in particular has been seen in the wild; it must be | ||
| # rejected rather than silently recovered. | ||
| with pytest.raises(ValueError): | ||
| get_selected_channels(bad_value) | ||
|
|
||
|
|
||
| def test_error_message_includes_field_name(): | ||
| with pytest.raises(ValueError, match="Channels to correct"): | ||
| get_selected_channels([0], field_name='Channels to correct') |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| # TODO-002: Malformed `channelCheckboxes` values from externally-authored tool configs | ||
|
|
||
| **Status:** Open (worker guardrail done; root-cause cleanup outstanding) | ||
| **Priority:** Medium | ||
| **Related PR:** _(this branch)_ | ||
|
|
||
| ## Summary | ||
|
|
||
| The `channelCheckboxes` interface type is documented (in `CLAUDE.md` and the | ||
| `nimbus-interface` skill reference) as returning a dict mapping channel-index | ||
| strings to booleans, e.g. `{"0": True, "1": False}`. All worker code was written | ||
| against that assumption (`[int(k) for k, v in value.items() if v]`). | ||
|
|
||
| A production Cellpose-SAM error log showed the values arriving as **lists** | ||
| instead: | ||
|
|
||
| ``` | ||
| "Channel for Slot 1": [0], "Channel for Slot 2": [], "Channel for Slot 3": [] | ||
| ``` | ||
|
|
||
| Calling `.items()` on a list raised an opaque | ||
| `AttributeError: 'list' object has no attribute 'items'` that crashed the worker | ||
| **before** its existing required-field check (`if not slot1...: sendError(...)`) | ||
| could run. So the safeguard wasn't missing — it was being skipped. | ||
|
|
||
| ## Root cause (confirmed) | ||
|
|
||
| The list shape is **not** produced by the worker, the front-end UI, or the docs: | ||
|
|
||
| - **Worker interface**: `Channel for Slot 1/2/3` declare no `default`, so | ||
| `getDefault("channelCheckboxes", undefined)` returns `{}` — an empty dict, | ||
| never `[0]`. | ||
| - **NimbusImage front-end**: `channelCheckboxes` is typed `Record<number, boolean>` | ||
| from the first commit; the `ChannelCheckboxGroup` widget only ever emits dicts; | ||
| the empty value is `{}`, never `[]`. | ||
| - **Backend** (`server/helpers/tasks.py`): JSON-encodes `workerInterfaceValues` | ||
| verbatim into `--parameters` — no transformation. | ||
| - Nothing in either repo constructs these values as arrays. | ||
|
|
||
| An empty `[]` for an unset slot is the tell: no code path generates `[]`, only | ||
| `{}`. These values were **persisted on a saved tool whose `workerInterfaceValues` | ||
| were written with the wrong shape** — channel slots encoded as arrays of selected | ||
| indices (the intuitive-but-wrong representation) instead of `{index: bool}` dicts. | ||
| The config is re-sent verbatim on every run, which is why it fails *consistently* | ||
| for those specific tools rather than randomly. | ||
|
|
||
| Fingerprints (tag `mg_cellposesam_v2`, name `cellpose-sam zero-shot on | ||
| LCA5_P21_rep1`) point to programmatic/scripted batch tool creation via the | ||
| API/Swagger that bypassed the UI widget, guessing `[0]` for "channel 0 selected." | ||
|
|
||
| **Conclusion:** the front-end never emits lists. A list always means | ||
| malformed/externally-authored input, never a real UI selection — so there is no | ||
| "list-form selection" semantic to preserve. | ||
|
|
||
| ## What was done (worker guardrail — DONE) | ||
|
|
||
| Added a shared helper `annotation_utilities.annotation_tools.get_selected_channels()` | ||
| that parses the documented dict form, treats `None` as "nothing selected", and | ||
| **rejects any other shape (including lists) with a `ValueError`**. Each affected | ||
| worker catches it and calls `sendError(...)` with a clear "interface is out of | ||
| date or misconfigured, please re-select the channels" message instead of crashing. | ||
|
|
||
| Affected workers: `cellposesam`, `registration`, `deconwolf`, | ||
| `histogram_matching`, `gaussian_blur`, `rolling_ball`. | ||
|
|
||
| We chose to **reject** rather than normalize-and-run: since these values come | ||
| from an external script that may have guessed the wrong channel, failing loudly | ||
| is safer than silently running a tool on a channel the user never confirmed. | ||
|
|
||
| ## Outstanding (the real fix) | ||
|
|
||
| 1. Find and fix whatever created the `mg_cellposesam_v2` batch with array-shaped | ||
| channel values, so it writes `{index: bool}` dicts. | ||
| 2. Optionally run a one-time normalization over saved tool configs to repair the | ||
| existing broken tools (otherwise they keep erroring until recreated). | ||
|
|
||
| Until then, affected tools get a clear error instead of a crash, but won't run. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,8 @@ | |
|
|
||
| from annotation_client.utils import sendProgress, sendError | ||
|
|
||
| import annotation_utilities.annotation_tools as annotation_tools | ||
|
|
||
| import imageio | ||
| import numpy as np | ||
|
|
||
|
|
@@ -118,12 +120,16 @@ def compute(datasetId, apiUrl, token, params): | |
| workerInterface = params['workerInterface'] | ||
| sigma = float(workerInterface['Sigma']) | ||
| channel = int(workerInterface['Channel']) | ||
| allChannels = workerInterface['All channels'] | ||
|
|
||
| print("allChannels", allChannels) | ||
| # Output is allChannels {'1': True, '2': True} | ||
| # This means that channels 1 and 2 are being blurred | ||
| channels = [int(k) for k, v in allChannels.items() if v] | ||
| try: | ||
| channels = annotation_tools.get_selected_channels( | ||
| workerInterface.get('All channels'), 'All channels') | ||
|
Comment on lines
+124
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Gaussian Blur is built from its worker Dockerfile, only this changed Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged, but leaving these (gaussian_blur, registration, histogram_matching, rolling_ball) as-is. They inherit Generated by Claude Code |
||
| except ValueError as e: | ||
| sendError( | ||
| "Could not read the 'All channels' selection. This usually means the " | ||
| "tool interface is out of date or misconfigured. Please re-open the " | ||
| "tool, re-select the channels, and run it again.", | ||
| info=str(e)) | ||
| return | ||
| print("channels", channels) | ||
|
|
||
| tile = params['tile'] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Cellpose-SAM Dockerfile copies this changed entrypoint from the build context, but still installs
annotation_utilitiesfrom a freshgit clone https://github.com/arjunrajlaboratory/ImageAnalysisProject/rather than the same checkout. In branch/PR builds where the remote default branch does not yet contain this helper, the image ships the new entrypoint with an older package and every compute reaches this call asAttributeErrorinstead of the clear malformed-channel error. Installannotation_utilitiesfrom the local build context, as the deconwolf image does, to keep the entrypoint and package version aligned.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in fc97d26. cellposesam now builds from the repo-root context (matching deconwolf/sam2) and installs
annotation_utilitiesandworker_clientviaCOPY ./annotation_utilities+pip installfrom the local checkout instead of cloning the default branch, so the entrypoint and the shared packages always come from the same checkout. Updatedbuild_machine_learning_workers.shaccordingly.Generated by Claude Code