Cleanup + backward-compat for secondary_object: make compartment_combo optional - #243
Conversation
tests/test_compartment_filter.py was committed with the feature; brieflow's test harness is tests/small_test_analysis/, not standalone pytest files. Removing it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intensity-scaling/noise augmentation clipped uint8 [0,255] training images to [0,1] and cast to float32, collapsing them to near-binary and mixing dtypes with the (uint8) originals — corrupting custom Cellpose training (on by default). Clip to the image's own range (np.iinfo max for integer dtypes, 1.0 for float), keep the augmented variants the same dtype as the originals, and scale the noise std to the range. Keeps training data in the same uint8 [0,255] space prepare_cellpose feeds the model downstream (cellpose normalizes internally at train + inference). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…minima apply_h_minima_suppression ran skimage h_minima on the distance/intensity peak map, marking valleys instead of peaks — so with maxima_reduction_factor set, the watershed seeds landed in background. Replace with the grayscale h-maxima transform (reconstruction of peak_map - h under peak_map), which flattens maxima shallower than h while keeping the map grayscale for peak_local_max. Rename the function + docstrings to h-maxima to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aggregate_second_objs validated merge keys on second_objs_df before use, so a well with no detected secondary objects (empty frame, no keys) raised 'Missing merge keys' and killed the whole aggregate. Short-circuit the empty case: keep all cells unchanged (as the 'none' strategy does for that well); they concat cleanly with object-bearing wells. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…all-NaN cells (from final_merge) and second_objs (from the phenotype parquet) can carry the plate/well/tile/cell keys at different dtypes (e.g. int32 vs int64, int vs float). A how='left' merge on mismatched dtypes matches nothing and silently fills every second-object feature with NaN. Coerce the second-object keys to the cells' dtypes before merging so matches land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The secondary-object pipeline gated on config['phenotype'].get('second_obj_detection',
True), so any screen that never set the key silently ran the whole feature and
picked up its phenotype/aggregate schema + path changes. Default to False so
secondary objects are strictly opt-in; existing screens get the standard pipeline
unchanged. Screens using the feature set the flag explicitly (as the small_test
config does).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_info (symmetric with phenotype) Mirror the phenotype alignment-metrics pattern on the SBS side: - add shared offsets_to_metrics(offsets, unit) helper in lib/shared/align.py - align_cycles() gains return_metrics kwarg; captures per-cycle offsets from whichever alignment branch runs (DAPI or sbs_mean) - scripts/sbs/align_cycles.py writes a one-row alignment_metrics TSV - align_sbs rule gets a 2nd output (metrics TSV); extract_sbs_info consumes it as input[1] so the shared extract_phenotype_minimal broadcasts the offset columns onto every cell row - register the metrics TSV target in targets/sbs.smk - mark offset_* columns as metadata in cell_data_metadata_cols.tsv Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ment toggle
The secondary-object feature stamped a compartment_combo dimension onto every
aggregate/cluster/bootstrap output path (_CmCo- token, /{combo}/ cluster dir,
__{combo}__ bootstrap segment) and required the column in the combo TSVs. This
broke backward compatibility for screens that don't use secondary objects:
missing-column KeyErrors and a forced full re-run from changed paths.
Add config aggregate.split_by_compartment (default False). When off, paths are
byte-identical to pre-feature output and a missing compartment_combo column is
tolerated; when on, compartment-specific paths are emitted as before. Column
filtering is already a no-op with all compartments present, so default mode is
a pure path/plumbing change with no effect on the data.
- new lib/shared/compartment_utils.py owns the conditional token/dir/suffix
emission, the wildcard accessor, and combo-table/record normalization
- Snakefile initializes the toggle + all-compartments default and normalizes
the aggregate/cluster combo tables at read time
- aggregate/bootstrap/cluster targets and rules route paths through the helpers
and resolve the combo via the accessor instead of a required wildcard
- metrics/cluster eval resolve legacy or compartment paths and gate the
compartment label in stats keys/columns
- small_test config sets split_by_compartment: true so the fixture keeps
exercising the opt-in feature
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-cell alignment-offset columns (offset_y/x, offset_*_stepN, offset_*_cycleN) broadcast onto phenotype_cp / sbs_info are QC diagnostics, not clustering features. Their names depend on the screen's alignment step/cycle count, so enumerating them in the metadata_cols file is screen-specific and easy to under-specify. Any offset_* column not listed silently falls through into the feature space (split_cell_data, filter, perturbation_score), polluting aggregation/clustering. Reserve the offset_ prefix as metadata via a shared is_reserved_metadata_col predicate, applied wherever metadata is split from features. No-op where offsets are already enumerated (e.g. the small_test config); closes the silent-leak path for real screens whose cycle/step count differs from the list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zed) The offset_* alignment-QC columns were hand-enumerated in the small_test metadata_cols file back when enumeration was the only way to keep them out of the feature space. The offset_ prefix rule makes that unnecessary, so remove the 32 offset_* lines: the fixture now models correct usage (screens shouldn't enumerate offsets) and actually exercises the prefix path. Also drop a stale num_nuclei mention in a comment. Verified: full small_test_analysis aggregate re-run 1221/1221, aggregated features carry 0 offset_ columns, per-cell tables still retain offset_* as metadata via the prefix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-on (deferred from this PR): per-cell
|
Cleanup pass over the secondary-object feature code: - Move the ML/classical parameter routing out of scripts/phenotype/identify_second_objs.py into a lib helper (segment_second_objs_from_config); the script drops from 161 to 59 lines and the unused nuclei_id_col scaffolding is removed. - segment_second_objs and create_empty_results now always return a 3-tuple (updated cytoplasm masks are None when no cytoplasm masks are given), so callers that unpack three values can't hit a shape mismatch; two dead identical if/else return branches are collapsed. - Narrow a bare `except` in extract_phenotype_second_objs to IndexError, so only a sub-3D array falls back to channel 0 and real errors surface. - After declumping, fill_holes only fills background pixels, so a filled hole cannot overwrite a neighboring object's pixels. - cellpose_training uses a local np.random.Generator instead of seeding the global RNG, makes augmentation seedable/reproducible, and keeps at least one training sample when only a single sample is available. - _aggregate_single preserves the input cell order across its split/merge. Verified with py_compile, ruff, and focused in-memory checks. The small_test gate runs second_obj_detection=false, so it does not exercise this path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
017909e to
b23a896
Compare
Code reviewFound 1 issue:
brieflow/workflow/lib/shared/metrics.py Lines 145 to 147 in b23a896 brieflow/workflow/lib/shared/metrics.py Lines 473 to 475 in b23a896 Reached unconditionally from brieflow/workflow/lib/shared/metrics.py Lines 804 to 806 in b23a896 The constant lives here, so either restore the re-export or repoint both imports at brieflow/workflow/lib/phenotype/constants.py Lines 2 to 5 in b23a896 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Follow-up: the zero-secondary-objects guard
brieflow/workflow/lib/aggregate/second_obj_utils.py Lines 48 to 51 in b23a896 brieflow/workflow/lib/aggregate/second_obj_utils.py Lines 56 to 60 in b23a896 Two problems with returning early there. 1. It drops the feature columns instead of NaN-filling them, which loses them screen-wide. brieflow/workflow/lib/aggregate/second_obj_utils.py Lines 161 to 164 in b23a896 The early return instead emits a well whose parquet lacks those columns. brieflow/workflow/lib/aggregate/filter.py Lines 62 to 65 in b23a896 Its own log line anticipates exactly this ("per-file missingness — typically driven by class composition differences across wells"). A screen with a class-pure control well can lose all secondary-object features and still complete successfully. 2. It also swallows the case where the error was correct. A per-well slice retains columns, so the empty-well case was never the aborting one. The abort is reachable when the whole screen has no secondary objects and the upstream parquet has no schema to slice — and there, aborting is right: the user asked to integrate secondary objects into a screen that has none, and should set Suggested fix, splitting the two cases:
That keeps the loud failure for genuine misconfiguration without breaking a correctly-configured screen that has a legitimately empty control well. 🤖 Generated with Claude Code |
Docstrings (non-blocking)Cosmetic, but the PR description claims more than landed. The Google-style conversion skipped the largest file. brieflow/workflow/lib/phenotype/segment_secondary_object.py Lines 1124 to 1137 in b23a896 Against the target style, in the same file: brieflow/workflow/lib/phenotype/segment_secondary_object.py Lines 565 to 575 in b23a896 Worth noting why ruff passes: Four return descriptions now contradict the code. brieflow/workflow/lib/phenotype/segment_secondary_object.py Lines 154 to 158 in b23a896 brieflow/workflow/lib/phenotype/segment_secondary_object.py Lines 403 to 412 in b23a896 brieflow/workflow/lib/phenotype/segment_secondary_object.py Lines 1368 to 1373 in b23a896
One stale arg description. brieflow/workflow/lib/aggregate/filter.py Lines 45 to 49 in b23a896 🤖 Generated with Claude Code |
|
Taking these three myself so they don't sit — pushing to
Shout if you're already mid-edit on any of these and I'll hold off. |
f43a39d removed `from lib.phenotype.constants import DEFAULT_METADATA_COLS` from cell_data_utils.py as an unused import. It was a re-export: metrics.py imported the constant *through* cell_data_utils, so both call sites now raise ImportError. Both are function-local imports, so py_compile and ruff do not see them -- get_all_stats() calls get_phenotype_stats() unconditionally and fails at runtime, and _calculate_batch_effects() fails under include_batch_effects=True. Import from lib.phenotype.constants directly rather than restoring the re-export. constants.py has no imports of its own, so there is no cycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r well 18b1b6d returned cells_df unchanged when a well had no secondary objects. That sits after the "none" short-circuit, so it only fires when the user explicitly asked for single/all/average, and it conflated two cases. A per-well empty slice is legal: second_objs_filtered is a boolean slice of the full parquet, so it keeps its columns, and _aggregate_single NaN-fills every cell -- the documented contract. Returning early instead emits a well whose parquet lacks those columns, and harmonize_pool_schema keeps only the intersection across wells, so one empty well silently strips the secondary object features from every well. A screen with a class-pure control well could lose them entirely and still complete. The case that should fail is the whole screen having no secondary objects while a non-"none" strategy is set. Check that in the script, where the unfiltered frame is still in scope, and drop the per-well early return so empty wells NaN-fill as documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ale returns 9d65852 converted file_utils.py, cellpose_training.py and second_obj_utils.py but left segment_secondary_object.py -- its only change there was relocating two functions. Convert the 12 remaining NumPy-style docstrings in that file. Ruff does not catch this: convention = "google" checks header punctuation, not body layout, which is why several docstrings carried a "Returns:" colon with NumPy dashes still underneath. Also correct descriptions the earlier commits invalidated: - segment_second_objs, segment_second_objs_ml and create_empty_results return an unconditional 3-tuple whose third element is None when no cytoplasm masks are given. Four Returns entries still said "if provided" / "optionally", which reads as a 2-tuple. Match _postprocess_secondary_objects, already correct. - harmonize_pool_schema documented kept_metadata_cols as a subset of the caller's metadata_cols, but 00c1b85 appends reserved offset_* columns that were never in that list. Docstrings only: ASTs with docstrings stripped are identical to b23a896 for both files. ruff check and ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed to
Verification: Not run: the |
Builds on #171 (
secondary_object, @akcd1) and targets that branch — it does not rewrite or force-push #171. It bundles some cleanup and fixes to the secondary-object code, plus the backward-compatibility work needed before the feature can land onmain. Happy to split any of this out or adjust if you'd prefer.1. Correctness fixes
[0, 1](which saturated every nonzero pixel whenintensity_scalingis on by default).h_minima, which produced poor watershed seeds).2.
compartment_combomade optional (backward-compat)The feature stamps a
compartment_combodimension onto every aggregate/cluster/bootstrap output path (_CmCo-token,/{combo}/cluster dir,__{combo}__bootstrap segment) and requires the column in the combo TSVs. For screens that don't use secondary objects this meant missing-columnKeyErrors and a forced full re-run from changed paths.New config flag
aggregate.split_by_compartment(defaultfalse):compartment_combocolumn is tolerated (defaulted to all compartments at TSV-read time).Because column filtering is already a no-op when all compartments are present, default mode is a pure path/plumbing change with no effect on the data. New
lib/shared/compartment_utils.pyowns the conditional token/dir/suffix emission and a wildcard accessor; theSnakefileinitializes the toggle and normalizes the combo tables at read; aggregate/bootstrap/cluster targets, rules, and metrics route through the helpers.3. Alignment offsets treated as metadata by prefix
The per-cell
offset_*columns (offset_y/x,offset_*_stepN,offset_*_cycleN) are alignment-QC diagnostics, not clustering features. Their names depend on each screen's alignment step/cycle count, so enumerating them incell_data_metadata_cols.tsvis fragile — any unlistedoffset_*column silently leaks into the feature space. A sharedis_reserved_metadata_colpredicate now reserves theoffset_prefix as metadata wherever features are split from metadata (split_cell_data,filter,perturbation_score), and the enumerated offsets are dropped from the small_test fixture (they're now recognized by prefix).4. Secondary-object code tidy
scripts/phenotype/identify_second_objs.pyinto a lib helper (segment_second_objs_from_config); the script is now a thin entry point (161 → 59 lines).segment_second_objs/create_empty_resultsalways return a 3-tuple now (cytoplasm isNonewhen none is given), so the callers' 3-value unpack is always consistent.except(→IndexError) so only a sub-3D array falls back to channel 0.fill_holesonly fills background pixels, so a filled hole can't overwrite a neighboring object.cellpose_traininguses a local RNG instead of the global seed, makes augmentation seedable, and keeps ≥1 training sample for a single-sample input._aggregate_singlepreserves input cell order.5. House-keeping
Removed the committed test file, collapsed multi-line comment blocks to single lines, moved to Google-style docstrings with public-first ordering in
lib/, and stripped unused imports / duplicate functions / debug prints.Validation (
small_test_analysis)split_by_compartment: true, so the fixture exercises the feature, including both bootstrap branches + cluster)._ChCo-*__…filenames andcluster/{channel_combo}/{cell_class}directories; off-mode paths compared byte-for-byte against the parent.py_compileclean.second_obj_detection: false, so the gate does not execute the secondary-object segmentation path. The section-4 changes there are verified bypy_compile, ruff, and focused in-memory checks rather than the gate.Follow-ups (not in this PR)
num_nuclei(multinucleation count) column — full wiring spec is in a follow-up comment below.find_optimal_resolution/analyze_all_resolutionshelpers had their signatures returned to the pre-feature shape; analysis notebooks that call them positionally will need a matching update on the analysis side.🤖 Generated with Claude Code