diff --git a/docs/validation/neurite-current-owner-controls-20261004.tar.gz b/docs/validation/neurite-current-owner-controls-20261004.tar.gz new file mode 100644 index 0000000000..5e2a80ac44 Binary files /dev/null and b/docs/validation/neurite-current-owner-controls-20261004.tar.gz differ diff --git a/docs/validation/neurite-detector-projections-20261003.tar.gz b/docs/validation/neurite-detector-projections-20261003.tar.gz new file mode 100644 index 0000000000..69b148f790 Binary files /dev/null and b/docs/validation/neurite-detector-projections-20261003.tar.gz differ diff --git a/docs/validation/neurite-graph-unit-checkpoint-20261005.tar.gz b/docs/validation/neurite-graph-unit-checkpoint-20261005.tar.gz new file mode 100644 index 0000000000..72f0c9008b Binary files /dev/null and b/docs/validation/neurite-graph-unit-checkpoint-20261005.tar.gz differ diff --git a/docs/validation/neurite-graph-unit-final-20261005.tar.gz b/docs/validation/neurite-graph-unit-final-20261005.tar.gz new file mode 100644 index 0000000000..cf74406f46 Binary files /dev/null and b/docs/validation/neurite-graph-unit-final-20261005.tar.gz differ diff --git a/docs/validation/neurite-installed-ack-20261005.tar.gz b/docs/validation/neurite-installed-ack-20261005.tar.gz new file mode 100644 index 0000000000..22a00f54a9 Binary files /dev/null and b/docs/validation/neurite-installed-ack-20261005.tar.gz differ diff --git a/docs/validation/neurite-pixel-coordinate-contract-20261003.rst b/docs/validation/neurite-pixel-coordinate-contract-20261003.rst new file mode 100644 index 0000000000..178730734f --- /dev/null +++ b/docs/validation/neurite-pixel-coordinate-contract-20261003.rst @@ -0,0 +1,785 @@ +Integrated rooted neurite analysis: explicit pixel-unit workflow +============================================================== + +Status and ownership +-------------------- + +Current implemented checkpoint: production39df6d49c; source/control history +8c0d9dd373b6e4cb2f074444c2211361e0b1bc7a and +76284064d48c15a69daf1e74fb1051a86b6da29e remains retained. Main +d5f660082e65b1d5f82985fbdcb0a353ad2a95dd was integrated normally; the eight +foreign dependency worktrees and retained untracked evidence were not changed. +The former registered-route implementation gap below is now closed in source. +Ordinary installed MCP execution and native graph-ROI reopening remain a +separate receiving boundary, coordinated with Planck's existing whole builder. + +The executable neurite_outgrowth_metaxpress_pixels declaration consumes the +ordinary 2D channel stack without a physical pixel_size artifact. It uses the +same detection, soma-rooted ownership, masks and measurement recipe as the +physical callable. Pixel-labelled settings feed inherited geometry hooks; +explicit pixel metrics produce CSV rows and feature-bearing graph ROI archives. +The original physical input/provider and SWC calibration guard are preserved. +This is source execution through real owners, not installed or biological proof. + +Singer owns the analysis declaration/unit boundary implementation. The original +Root394 exclusive claim below is historical:394 is merged and no current unit +owner overlap was found. Singer now owns the narrow spacing/graph/export seam. +Parent owns future installed/public acceptance. No scientific author contact. +Existing isolated checkout reused, mainf910830954646b4c38c6ff46ec0a2f78b1d3addb; +522 and539 published branches/evidence are retained without further mutation. +Foreign gitlinks/untracked validation work remain unchanged. + +Open issue/PR claims were checked:424 owns the existing soma pixel-size gate, +390/391 help projection is merged, and435/394 owns publication. None supplies +an integrated pixel-unit rooted neurite declaration. The compact analysis blob +45f7f22401b93f1c433f7b5f9639fef17b0d157d is identical on current main,522, +main306512de and Root394f12250993d91dfa38d5de548fed0fabbaa107e61. + +Determining contract +-------------------- + +neurite_outgrowth_metaxpress declares artifact_inputs("pixel_size"). Its +MetaXpressCellBodySettings/OutgrowthSettings and inherited nuclear widths are +physical controls. PixelSizeMetadataArtifactProvider, microscope_interfaces.py +238..253, explicitly resolves micrometers per pixel. PathPlannerMetadataArtifactInjection +576..604 resolves that provider before invocation. OpenHCSMetadataHandler.get_pixel_size +312..322 delegates declared source spacing to SourceVoxelSpacing's physical +projection. RELATIVE is dimensionless and correctly cannot provide it. + +SourceVoxelSpacing.require_physical_pixel_size404..416 rejects missing absolute +calibration, anisotropicXY and mixed/conflicting sources. Root's corresponding +903..914 implementation retains the same strict contract. This guard is correct. +No supplied1.0, changed source units, copied callback or caller bypass is a fix. + +The registered compact callable has no pixel-coordinate unit selector or pixel +settings/results declaration. Its direct default HiddenPixelSize(1.0) is not a +supported compiled pixel route. NeuriteOutgrowthCellResult400..408 mentions pixels +under legacy _um headings, contradicting the actual required physical artifact. +Those headings are not proof of micrometer calibration and must not be dropped +or silently repurposed to make an uncalibrated run appear valid. + +The shared computation is already present: segmentation, soma-rooted path +partition, per-cell rows, labels, diagnostics and _build_neurite_morphology_graph. +Lengths use skan spacing; areas use spacing squared. Graph coordinates remain +source-pixel indices, coordinate_spacing carries conversion and node radii/edge +features currently claim physical units. SpatialGraph carries source provenance +but no explicit analysis-unit field. The SWC writer's _swc_xyz applies that +spacing and describes physical coordinates. Therefore a pixel route needs an +end-to-end typed unit contract, not merely successful numerical invocation. + +Existing alternatives do not establish equivalent capability. The CP +measure_object_skeleton family has seed-relative pixel measurements but not the +compact unified-neuron/rooted-forest contract. skan_axon_skeletonize_and_analyze +has physical voxel_spacing and network/branch outputs, not that soma ownership. +HMM tracing has its own seeds/graph dictionary contract. No alternative is +claimed absent or scientifically invalid; none is the missing integrated route. + +Retained reproducer and limits +----------------------------- + +Coordinator-only original evidence under the public94 author output: +REPORT.md, final/pipeline.py and evidence/098-openhcs_get_execution_status.json. +The original compile job reports the physical scalar calibration exception; +the saved status traceback is explicitly truncated5855characters. The fallback +does not execute the compact rooted analysis. No raw image, held-out answer, +reference annotation or measurement CSV was opened for this investigation. +Original rejected/frozen outcomes are unchanged. Rejection of a particular +ownership-dependent metric does not imply every algorithm-defined estimate is +useless; graded quality policy is separate539, not a calibration workaround. + +Source evidence +--------------- + +Latest NRA skill and authoritative refactor-audit ZIP plus identity, +implementation and boundary catalogs were read. Relevant patterns:IDEN-1/2 +(numeric scale versus unit identity),BOUND-2 (use the existing source frame), +BOUND-8 (do not flatten units to a scalar),IMPL-12 (do not copy the topology). + +Original full-package overlay01 hit enforced512MiB OOM,Swap0; retained, no cap +increase or clean-scan claim. Original debt_census02 subsequently parsed the +whole704-module production package with zero parse omissions,16.98s/maxRSS45836KiB, +oneCPU512MiB/Swap0. This is structural source coverage, not full NRA R1, +dependency-family closure or behavioural proof. family-consumers03.txt records +the related declaration/consumer source search; it is not AST resolution. +Complete related dependency/consumer facts remain necessary before structural +implementation. No tests, builds, installations or native launches occurred. + +Byte-exact source evidence is neurite-pixel-coordinate-contract-20261003.tar.gz; +original logs remain in the issue-batch engineering-neurite-units-20261003 root. + +Receiving design and acceptance +------------------------------- + +Keep the physical calibration guard and physical result schema. Extend the +original source-frame/unit and analysis owners with a genuine pixel-coordinate +declaration, explicit pixel controls and unit-correct row/graph projections. +The existing integrated algorithm remains one implementation. Shared mechanisms +belong on their behaviour-owning ancestor; unit declarations supply small +conversion/projection hooks. Independent source provenance and unit capabilities +compose through their existing cooperative inheritance, not a consumer mode +switch, mirrored unit table, cloned callable or wrapper invoking1.0. + +A synthetic known soma/branched path must compile and execute through the +registered pixel-unit contract without physical calibration; measurements and +graph/archive readback must explicitly retainpx/px2/source coordinates. +The same known geometry with genuine calibration must preserve the physical +contract and correct scaling, while relative/conflicting/anisotropic sources +still fail physical scalar admission. Adding another declared unit/frame case +must require its own hook/declaration, not generic consumer edits. Real source +controls precede separate ordinary installed/public acceptance. No biological +accuracy, rooted ownership or merged/installed readiness is claimed here. + +Exact shared dependency: Root's release/integration of any source_metadata.py, +runtime_spatial_graph.py and materialization/core.py unit-frame/projection hunk. +This checkpoint requests that boundary, not permission to alter their checkout +or merge whole394. Independent analysis declaration work can proceed after the +complete owner pass; original failed inputs and sourceguard remain protected. + +Current independent continuation +-------------------------------- + +Dewey verified the complete immutable567 source archive8513 before releasing +this existing checkout: engineering567/build-input01/source8513.tar SHA256 +02fe1abad6a2c1a74a9f0b6f9d54b289b9fc78b8214c9d4f38a9e5ca2e3c01a9, +original Git archive commit8513a7d2483ea09a2ec6b9e9f0193a5a16a56902. +No corrected567 wheel/native acceptance is inferred from that source archive. +The finished checkout switched back to this original541 branch, then normally +merged mainf2066ded at f542d411f39da7a477675bce3b3078518a40cf25. No conflicts, +foreign gitlink reset, scientific package or borrowed source change occurred. + +Original Package parser source-family04 completes the relevant source evidence +at8513:704 OpenHCS +12 python-introspect +6 metaclass-registry +17 arraybridge +modules, zero parse omissions,11.187s/235.3MiB/Swap0/terminal0 under original +common CPU1/512MiB/60s limits. Complete selected ASTs include declarations, +inheritance, imports, constructors and reads/writes/consumer bodies; not just +the earlier textual source search. Evidence stays under the existing +engineering-neurite-units-20261003 root. JSONL SHA256 +55eb03064f95b86b301c7ed46b82703340cd63bdb42f7239507cf041fc9b3ece. +These are preserved dependency source revisions, not installed-runtime proof. +This pass is prior8513 source evidence, not an assertion that every later main +or Root module is unchanged. No repeated global R1/OOM/test batch was run. + +Determining current consumer contract: FieldSpec.from_dataclass_type derives +columns from original nominal fields; DataclassMeasurementColumnarRows does +not provide a unit-key renaming API. The shared numerical statistics and +topology therefore need physical/pixel declaration-owned row hooks, not raw +column replacement. Existing NeuriteOutgrowthCellResult falsely described +uncalibrated pixels under its _um fields. That source docstring is corrected +to the actual compiled physical contract; signatures, fields, numerics and +guard are unchanged. This is a source clarification, NOT the pixel feature. + +Actual Root3940bca source contents were inspected: spacing unit remains only +MICROMETERS/RELATIVE; SpatialGraph carries coordinate_spacing and physical +radius but no explicit analysis/export unit. Its source_metadata/graph blobs +are d13f23bb/db26f654, different from8513, so no old-hunk reapplication is +claimed. Latest Root headf3c66cd7 remains separately owned. The narrow request +is public at394 comment5975214247; receiving status at541 comment5975217712. + +Requested original-owner seam: explicit PIXELS member on SourceVoxelSpacingUnit +with no physical scalar projection; reused typed analysis spacing/frame distinct +from acquisition provenance; explicit graph analysis/export unit alongside its +existing scale/radius; original graph/ROI publication carries that declaration. +Original SWC admission retains the physical format contract. A pixel declaration +can select supported graph/ROI formats without inventing physical SWC units. +No source calibration relabel, copied units roster or generic consumer switch. + +Singer's settings/measurement/topology continuation remains independently owned; +Root release/integration of that exact shared seam is needed for an end-to-end +pixel graph. No unused parallel analysis implementation or unsupported wrapper +has been added while this dependency is unresolved. Existing physical guard is +preserved;541 pixel route is still unimplemented and not installed-qualified. + +Independent detector projection checkpoint +------------------------------------------ + +Current394f3c66cd7 file claims and all open PR titles/heads were rechecked. +No active analysis settings owner overlaps this hunk; Root's source metadata, +graph and materialization files remain untouched. Dewey builds567 from its +immutable8513 archive, not this reused checkout. #131's new uncertain revision +is preserved and is not a registration receiving input or replay authorization. + +The existing settings now own their dimensional projections. Body maximum +width/minimum area live on MetaXpressCellBodySettings, outgrowth width on +MetaXpressOutgrowthSettings, and nuclear wavelength bounds on the existing +MetaXpressWavelengthSettings ancestor. Primary detection, nuclear propagation, +signal-body filling, neurite detection and the compact registered physical +recipe consume those hooks. Five dimensional expressions remain solely at +their five declaration hooks; the competing consumer expressions are deleted. +No settings mirror, units roster, decoder or second detection algorithm exists. + +This is the working physical detector seam needed by the pixel continuation, +not a new registered pixel callable. Physical artifact admission, settings +fields/defaults, result headings, topology spacing and graph/export semantics +are unchanged. Root's analysis/export unit integration is still needed before +the pixel route can be enabled honestly. Analysis and export unit identity are +not inferred from numeric one or acquisition-relative calibration. + +New-case source controls use declaration-only projection hooks through the +original body detectors and wavelength ancestor. Independent audit and width/ +area capabilities compose with cooperative super and declared C3 MRO. The +compact physical callable must produce identical labels, physical rows and +graph for equivalent projected controls; no generic consumer edit is needed +for these new declarations. These controls have been authored after the +coherent owner change; qualification is pending at this source checkpoint. + +Original complete8513 AST/dependency evidence remains applicable to the +unchanged underlying family; the determining two analysis modules differ from +8513 only by the prior physical-schema doc clarification before this hunk. +Changed-module AST and original pinned R0 will qualify this actual delta, +followed by a bounded focused source batch. No global R1, installed/public +pixel acceptance or autonomous scientific improvement is claimed. + +Projection qualification at5dad36f88 +----------------------------------- + +Source19 controls PASS, terminal0,24.658s/364.2MiB/Swap0/OOM0, under the +unchanged common slice with CPU1/512MiB/60s. This includes the existing body +gate family, all three new primary/nuclear-seeded/signal-body declaration cases, +the shared wavelength detector's cooperative hooks, unchanged public signatures +and the original compact physical callable. Equivalent declared projections +produce identical body/outgrowth/nucleus masks, image/cell physical rows and +the complete spatial graph. The raw input is unchanged. New declaration/audit +capabilities compose through actual super calls; these are behavioral controls, +not merely inheritance assertions or word matching. No new registry or generic +consumer switch was required. The source bootstrap is the original runner from +the immutable567 archive, with the reused541 checkout explicitly selected. +This is source-runtime qualification, not installed-wheel/public pixel proof. + +Before controls, the original Package parser parsed the current23-module +analysis family with zero omissions and retained complete changed-module ASTs. +Its installed tool source SHA03167cc1 is preserved; it is not the authoritative +ZIP's later parser blobdfdddeb7. Original debt_census.py SHA +fbe4651372d4d79963075d7fb6ba6dedf90d5e88e14eee07f845c5d836974e35 +does match the authoritative ZIP. Its exact two-file R0 versus21e1ca313 has +ALL measured counts zero, code lines+16;0.682s/20.2MiB/Swap0/terminal0. +Prior full8513 consumer/dependency evidence remains separately retained. +No global R1 or unchanged Root dependency claim is made. + +Original R0 invocation05 stopped before measurement because --json lacked its +required output path. Original controls05 stopped before collection because +the transient service lacked WorkingDirectory. Both terminal errors/raw logs +are retained unchanged. Corrected distinct R0 invocation06 and controls06 +used the original tools/bounds; no assertion, test input or resource cap was +weakened. No fixture runtime was UNKNOWN and no native operation was submitted. +Two existing pytest config warnings remain in the complete original output. + +Byte-exact changed source/tests, original parser caller/AST, both known harness +failures and final R0/control outputs are archived in +neurite-detector-projections-20261003.tar.gz,222071bytes,SHA256 +ab3811ce8cfa42e672aa7fb6972f42182323a60dc89f9f99fd5718c9b817fd96. +Loose originals remain under engineering-neurite-units-20261003. The owned +terminal Numba fixture cache is2.1MiB there; no source, foreign ledger or prior +scientific evidence was cleaned or rewritten. + +Remaining dependency is precise: Root394's analysis-unit-bearing graph/export +contract before a pixel registered route can publish unit-correct rows/graphs. +This independent settings checkpoint is working; it neither claims that shared +integration exists nor changes physical calibration admission. #567 still uses +Dewey's separate archived8513 candidate. Its public observation case awaits +whole-package qualification and positively observed original131 client/native +closure on6012; the newly retained uncertain revision is never replayed. + +Current-main receiving checkpoint, 2026-10-04 +------------------------------------------- + +Normal merge7916ddad2c270c40177d3def81d62009cbb67464 integrates main7fb3c09b, +including accepted594 declaration descriptions, without changing the five +owned detector projections. Planck released the finished source checkout: +ordinary594 package/receiving uses its immutable private target and a different +source checkout. All six dirty foreign gitlinks, seventh gitlink absence and +untracked historical evidence are preserved. No new worktree, environment, +installation, native process, scientific input or author contact. + +Latest NRA/refactor-audit skills and authoritative archive patterns were read. +Required relation remains distinct acquisition calibration versus declared +analysis/export units (IDEN-1/2), original typed spacing/graph/materialization +owners rather than mirrored units or raw field renaming (BOUND-2/8), and one +shared detection/topology recipe with declaration hooks rather than copied +procedures (IMPL-12). The working independent hooks remain on the existing +body/outgrowth settings and wavelength ancestor, not a new forwarding facade. + +The original source-family04.py caller was reused unchanged against merged7916: +704 OpenHCS,12 python-introspect,6 metaclass-registry,17 arraybridge modules; +zero parse omissions, retained complete related ASTs and dependency identities. +Terminal0,12.46s,213532KiB maximum RSS, process swaps0. These are source +declaration/write/read/import/MRO facts, not global R1 or runtime acceptance. + +Current18 focused source controls PASS, terminal0,11.53s,438776KiB maximum RSS, +process swaps0. Whole body-gate family, primary/nuclear-seeded/signal growth, +inherited wavelength cooperative hooks, original compact physical callable and +both public signatures are exercised. The unchanged source input, masks, +physical rows and graph equality controls remain intact; new declaration-only +capabilities execute their cooperative hooks without generic consumer edits. +This independently qualifies current-main integration, not a pixel route. +Original historical19-control receipt remains unchanged. Two existing pytest +configuration warnings are retained. Shared historical cgroup peaks are not +attributed to this test. No invented memory/swap cap was restored. + +Exact current two-production-file R0 against main7fb3: every measured count +delta0, code lines+17. Original old-base code+16 receipt remains historical; +neither result is a global audit or a waived positive switch delta. + +Determining shared dependency, not historical status +-------------------------------------------------- + +Actual Root394 head a0bb0f4089a85475e112cea32d60132c1cd94752 was fetched and read. +SourceVoxelSpacingUnit at source_metadata.py764 declares MICROMETERS/RELATIVE +only. SpatialGraph at runtime_spatial_graph.py190 still carries bare +coordinate_spacing and physical radii, with no explicit analysis/export unit. +The original ROI writer at processing/materialization/core.py3411 constructs +SourceVoxelSpacing(graph.coordinate_spacing), selecting its physical default. +The original SWC writer scales coordinates using the bare graph spacing. +Complete ASTs for these three actual shared modules are retained separately; +three parsed,zero omissions. This corrects the obsolete core/materialization +path in earlier receiving prose; its original record is preserved above. + +Root remains the exclusive shared-file owner. The current narrow release or +integration request is394 comment5978234373, following5975214247; no release +or existing explicit pixel analysis frame was found in the inspected current +claims/source. The required hunk is original spacing-unit/graph/export behavior: +explicit pixel analysis unit without physical scalar calibration, typed analysis +spacing carried by graph/export independently of acquisition provenance, and +original physical SWC admission retained. Unit/format policy belongs on those +owners. No generic switch, alternate exporter, calibration1.0 bypass or unused +parallel measurement implementation is added while this seam is unavailable. + +The registered pixel row/graph route remains unimplemented. This is the precise +Root dependency, not a hosted-CI, acknowledgement, test or package hold. After +the shared contract is integrated/released, Singer owns the single measurement +construction/topology consumer migration and source qualification; ordinary +installed/public pixel receiving then requires a released engineering lane. + +Separate new source-projection witness +------------------------------------- + +Read-only original BBBC007 log950..1166 in +next-bbbc00796-ownp00188-after593-20261004/BBBC007_PUBLIC593_96/author-workspace/ +output/runtime/scratch/data/openhcs/logs/ +openhcs_zmq_server_port_6016_1791102257189925629.log records first +PersistNuclei01 conversion failure before the writer. A01 has runtime_slice +size8 versus returned(4,450,450); A02 has RUNTIME_SLICE versus SOURCE_BINDING. +Original attempts/candidate01_first.py and failed job remain untouched. +ImageOutputRecorder.record reaches contextualize/output_owns_source_context, +then ImagePayloadMetadata.has_complete_source_identity and plane validation. +This identifies the determining boundary, not the earlier wrong producer. +No guard is weakened and no original request is replayed. + +Those five related Root production modules materially differ from installed +main7fb3. Current Root reproduction/fix is NOT claimed. Dewey combined this +witness with the existing P001 source-assembly observation in the SAME +Root394 comment5978205597; there is no duplicate issue/patch or scientist +feedback. It is distinct from541's unit-bearing graph dependency. + +Current raw controls/source evidence is archived separately in +neurite-current-owner-controls-20261004.tar.gz,4910507bytes,SHA256 +a03ddd1f91be288d3d0cb7604df8aedaeb7d094415d8b925d4e6f37728885b49; +the original earlier archives, +failures and loose originals remain intact under engineering-neurite-units- +20261003. The current source checkpoint changes no shared Root implementation. + +Current unit-owner continuation after522 source repair +----------------------------------------------------- + +522's coherent repair is published atd4ff1cbbf; Planck explicitly uses its exact +Git export and holds no mutable source checkout borrower. Singer reuses this +same checkout for541, normally integrating mainb461 at221357aca without conflict. +Eight foreign gitlink worktrees and all untracked evidence remain unchanged. +Active727's declared files do not overlap this unit/graph/materialization family; +closed394 is not a continuing exclusive claim. + +Original Package caller source-family04 was reused, not copied into a new +scanner. Current source-family08.jsonl parses702 production/12python-introspect/ +6metaclass-registry/17arraybridge modules with zero parse omissions; terminal0, +9.59s/208644KiB maxRSS/process swaps0. Full related ASTs and exact dependency +Git identities are retained under engineering-neurite-units-20261003; SHA256 +72fbefe68c7ae93c99ed435bba870bf38d1d03fc8ba3a11dcdd953d0c87d8038. +This is source evidence, not current installed dependency or global R1 proof. + +The registered pixel-rooted route remains unimplemented. The next owner batch +must distinguish analysis metric units from acquisition/native calibration: +SourceVoxelSpacing owns declared units; SpatialGraph still carries a bare +coordinate_spacing tuple and physical radius convention. The ROI writer builds +SourceVoxelSpacing(graph.coordinate_spacing), implicitly asserting micrometers; +SWC also exports scaled coordinates/radii without unit admission. A pixel +analysis must not relabel an acquisition's spacing, assert1um, or silently emit +physical SWC. Settings, hidden input/provider, measurement rows, graph and both +export consumers must use the same declared coordinate contract and shared +analysis implementation. The original physical negative remains required. +No541 production edit, test, package/runtime action or scientific feedback is +claimed by this continuation checkpoint; semantic consumer review is active. + +Working typed graph/export checkpoint, 2026-10-05 +------------------------------------------------ + +This is an implemented source checkpoint, NOT a freeze-ready registered pixel +analysis route. The production delta adds PIXELS to the original +SourceVoxelSpacingUnit declaration. Its member-owned physical projections return +no calibration; SourceVoxelSpacing.require_physical_coordinates rejects pixel +and relative SWC exports. Physical anisotropic coordinate export remains valid; +the existing physical scalar calibration guard remains unchanged. + +SpatialGraph now composes SourceImageProvenanceFields and SourceVoxelSpacingFields +with NamedArtifactPayload. Its required coordinate_spacing is the original +SourceVoxelSpacing value, expressing the analysis metric. Inherited acquisition +spacing remains a distinct fact. Graph contextualized_source_metadata owns the +one source-plane/provenance/calibration binding recipe; the artifact consumer's +copied projection/replacement is deleted. All current production and test graph +constructors use the nominal spacing. The old provenance-only hook is deleted; +no tuple compatibility reader, unit mirror or alternate graph writer exists. +Historical frozen receiving drivers remain unchanged, not current consumers. + +The existing graph ROI writer uses inherited acquisition calibration rather than +reinterpreting analysis spacing as micrometers. SWC uses the analysis owner's +physical admission. The physical neurite producer explicitly declares its actual +micrometer metric. Source coordinates, graph node/edge identity and geometry are +unchanged. Independent ProjectionAudit capability executes the cooperative graph +hook through super; no generic artifact/writer edit is needed for that case. + +Original graph-unit-controls09 ended before collection, terminal2: the borrowed +foreign PolyStore checkout lacks TiffPhotometric. The unchanged original runner +then used the qualified receiving16 read-only PolyStore backing and startup API, +without any install or foreign source edit. Distinct graph-unit-controls10 passed +27 controls, terminal0,3.83s/250072KiB maxRSS/process swaps0. These exercise original +graph/SWC/ROI behavior, both nonphysical export negatives, pixel analysis with +physical acquisition calibration preserved in the real ROI archive, source +projection and the independent cooperative hook. Two existing pytest config +warnings remain. Original failure and complete output are preserved. + +Source evidence preceding this owner batch is complete source-family08 above, +not global NRA/R1. After-source family and five-path original R0 are the next +source qualification; no inherited512MiB cap has been imposed. Applicable current +catalog patterns are IDEN-1 (two distinct metrics), BOUND-2 (existing spacing and +metadata owner), IMPL-2 (unit member behavior) and IMPL-12 (deleted projection +recipe). The historical archive's BOUND-8 references are not current catalogue IDs. + +Exact controls/logs live under the existing engineering-neurite-units-20261003 +receipt root: graph-unit-controls09.stdout/stderr and +graph-unit-controls10.stdout/stderr. No original negative was overwritten or +replayed. No wheel, installed pipeline, native/viewer or biological claim. + +Remaining implementation is explicit: registered neurite analysis still requests +the physical pixel_size artifact and emits physical-only row/edge headings. Its +default materialization includes physical SWC. These input/provider, row schema, +metric computation and export declarations must be closed together before an +uncalibrated compiled pixel route is supported. This checkpoint does not bypass +that guard with1.0 or claim a pixel route through direct function invocation. + +Independent522 source-owner rendering disposition +------------------------------------------------ + +Read exact OWNER-PUBLIC-ACCEPTANCE16.rst and original Napari Shapes code at the +existing paired dependency root. Shapes._outline_shapes constructs a separate +selection outline from edge centers plus normalized_scale_factor times highlight +width times edge offsets. VispyShapesLayer._on_highlight_change renders those +triangles into shape_highlights, separately from shape_faces. Selected geometry +therefore has a presentation overlay that disappears when deselected. + +The retained three native PNGs and ordinary presentation-only deselection show +that separation: the compact polygon remains aligned while the yellow highlight +disappears, without source-row reselection or retirement replay. A concrete data +geometry/remount regression is not demonstrated. The expected selected-overlay +explanation is source-backed; the exact star silhouette is not an assertion of +unchanged native vertex arrays, which the public contract does not expose. + +Accept the installed-live contract demonstrated by receiving16: extent3->2, +A03 ordinal2->1, Points selected_data[1] and Shapes[3] retained, source/frame and +immediate pre/post camera retained. Canvas428->426 pixels and the occluded before +outline preclude a fixed-canvas/pixel-identical renderer comparison. These limits +do not block the working retirement workflow or justify another source patch. +All originals are closed and sealed; no new client or uncertain operation replay. + +Final graph owner/source-family qualification +------------------------------------------- + +Production64d65596b deletes the extra constructor type check introduced by the +first checkpoint. The declared SourceVoxelSpacing field and original codec own +nominal input; SpatialGraph consumes its values directly. A tuple still fails +instead of being silently coerced to micrometers. This is not removal of the +physical export/admission guard. Original R0-11's +1 isinstance is retained; +the corrected five-production-path R0-16 has every measured count delta0, +code lines+15. No positive delta is omitted or waived. + +Complete after-source-family16 uses the same original Package caller, parses +702 OpenHCS/12 python-introspect/6 metaclass-registry/17 arraybridge modules, +zero omissions at64d65596b. Terminal0,14.38s/214296KiB RSS/process swaps0. +JSONL SHA25642b6e572f57821b13bbf76e01fa16af1721142a0ccabe10656844b3b37e266e0. +Complete declaration/read/write/import/MRO consumer ASTs are retained, not a +new scanner or a global R1 claim. + +Expanded final-source graph-unit-controls13 has95PASS and ONE fixture-setup +error: viewer_ack_return_route is disabled by the original runner's noconftest. +No assertion failed. Controls12 is the earlier known collection error because +the cross-test helper path was absent. Explicitly loading the original conftest +in14 is a known pre-collection refusal: repeated source-dependency bootstrap +rejects the read-only matched PolyStore backing, which this foreign source WT +does not provide. No bootstrap guard, test input or assertion was weakened; +these originals remain, and the single public-streaming fixture is unqualified +in this source bootstrap. It needs the ordinary matching whole dependency/source +environment, not an invented acknowledgement or a selective product overlay. + +Independent controls15 has6PASS/76deselected, terminal0,11.33s/378620KiB RSS, +process swaps0: the actual compact physical recipe through independent settings +MI/cooperative hooks, rooted feature-bearing graph, source-plane provenance, +cycle breaking, detached component integrity and colliding endpoint identity. +Together13/15 confirm101 distinct passing controls, not102 or a green whole +suite. Two original pytest config warnings remain. No native server or actual +viewer was started. Shared historical cgroup peaks are not process consumption. + +The next registered-input owner is already available: RuntimeAdapterRequest +carries the original source_payload before array conversion. The existing +runtime_adapter mechanism injects nominal ImagePayloadMetadata for registered +acquisition_tile_positions; source_metadata_by_payload/for_source_plane own +ordered per-plane spacing/provenance. The neurite continuation can consume +that original source contract rather than introducing a plate calibration +mirror or falsely treating RELATIVE as physical. Physical analysis must still +admit actual common isotropic micrometer spacing; pixel analysis must explicitly +declare its metric and matching settings/rows/graph/export projection. + +No remaining Root394 release or external acknowledgement blocks this work. +Remaining implementation is the unit-correct registered analysis/row/export +family stated above, not cleanup. Public #522 source-owner disposition is posted +at comment6000052680; #522 is mergedcc94228ce3, not awaiting vertex-array proof. + +Complete final graph checkpoint/archive: +neurite-graph-unit-final-20261005.tar.gz, SHA256 +3d37aebb2ad3aebc4b233ed988575e702e1982c32edfb5944994584f63cff685. +Contains all five production paths, migrated graph consumer tests, original +known negatives09/12/13/14, both positive source shards, original positive +R0-11 plus corrected R0-16 and full after-source ASTs11/16/caller. The earlier +checkpoint archive remains immutable. Current final source production is64d65596b; +subsequent receipt changes do not imply another tested production version. +All owned source-check handles are terminal, no provider/native/viewer started. + +Executable pixel-input, rows, edges and output declaration closure +----------------------------------------------------------------- + +Production8c0d9dd37 moves the single numerical recipe off the physical public +function onto existing CellProfilerNeuriteEngineProfile.analyze. Both public +declarations consume that ancestor. Original helper algorithms remain single; +no second segmentation, topology, table, metadata or export engine was created. +CellProfilerNeuriteEngineProfile.artifact_outputs declares the common twelve +artifacts once. Its morphology_output hook supplies the physical SWC+ROI +declaration; PixelMetricCoordinates uses cooperative super to retain original +identity/relations and declare only SpatialGraphROIOptions. Genuine C3 MRO is +PixelCellProfilerNeuriteEngineProfile, PixelMetricCoordinates, +CellProfilerNeuriteEngineProfile, object. An independent audit capability +executes before/after analysis and export hooks without generic consumer edits. + +PixelCellBodySettings, PixelOutgrowthSettings and PixelNuclearSettings override +only geometric field declarations/docs and inherit original validation and +projection behavior. Pixel widths and scoring lengths are pixels, body areas +square pixels. Intensity fields still describe consumed-image intensity units. +The pixel declaration has no pixel_size argument/artifact dependency. The +physical declaration still requires original micrometer calibration; neither +relative nor missing source calibration is silently admitted as physical. +The two callable declarations construct isotropic metrics before the shared +algorithm; repeated optional/type admission inside that algorithm is deleted. + +Current measurement/edge schema is an explicit cutover: quantity names no longer +assert _um/_um2; coordinate_unit is the original SourceVoxelSpacingUnit, and +areas use its square. Physical measurements declare micrometers, pixel +measurements pixels. The original unit enum now uses Python StrEnum so original +CSV/codec owners project its declared value directly, not a new codec or column +rename adapter. Every current neurite row factory, summary/edge consumer and +affected test migrates in the same family. Separately physical neuronal-soma +count rows remain unchanged. Frozen CSVs, recipes and historical evidence are +not rewritten and no compatibility aliases/readers were added. + +Graph analysis spacing and acquisition spacing remain different owned facts. +SpatialGraph's original metadata contextualization supplies native provenance, +source plane and acquisition spacing after the recipe. Pixel analysis needs no +invented metadata input/provider: its metric is explicit and independent of +acquisition calibration. Original graph ROI writing keeps the acquisition +spacing; original SWC writing rejects nonphysical analysis units before artifact +creation. None of the saved-result source-receipt guards was relaxed. + +Final source checks and original outcomes +---------------------------------------- + +All commands reuse the original viewer-retirement source bootstrap, paired +interpreter, matched receiving16 dependencies read-only and retained Numba +cache. No install, build, native/viewer/MCP server or scientific input was used. +Actual resource check reported12.4GiB available RAM, existing16.4GiB swap and +disk warnings. One CPU/60s source shards completed without process swaps; +there was no invented memory/swap cap. Shared historical cgroup peaks printed +by the original runner are not these processes' allocation. + +Original pixel-route-controls17:13PASS/3FAIL/152deselected, terminal1, +34.80s/523312KiB RSS. All three failures are the test's string argument to +original load_rois_from_zip(Path), after pixel computation, CSV and ZIP writing. +Only that test call was corrected to Path; assertions/reader/product unchanged. + +Final production pixel-route-controls18:22PASS/146deselected, terminal0, +11.90s/453396KiB RSS. Actual registered contract and original source-plan compiler +admit the pixel recipe without a physical artifact; physical contract still +declares pixel_size. Tiny branched/unbranched source arrays remain unchanged; +physical/pixel masks and topology geometry match, lengths scale2 and areas4 at +the declared half-unit physical metric. CSVs contain explicit pixels, and actual +graph ROI ZIPs preserve vertices/features/provenance and empty, relative or +1.3556 micrometer acquisition calibration. Explicit pixel SWC rejects before +creating a file. Cooperative independent metric/export capability executes. +Original physical uniform/anisotropic/missing-calibration controls also pass. + +pixel-route-controls19:73PASS/1FAIL/14deselected, terminal1, +25.46s/465988KiB RSS. The sole failure was an existing test's string traversal +through the CellProfiler facade rather than its actual skeleton module, before +its forbidden-remeasurement assertion could run. The test now imports the real +module and patches the same function; the assertion was not removed/weakened. + +Consumer-test checkpoint76284064d, pixel-route-controls20: +15PASS/1SETUP-ERROR/113deselected, terminal1,17.15s/532756KiB RSS. +The corrected original forbidden-remeasurement check passes. Added nuclear +pixel/physical pairs match labels, rooted geometry and metric scaling. +Original physical SWC writer/reader/topology and graph-source roundtrip controls +pass. The original all-public-output streaming test still lacks its +viewer_ack_return_route fixture under the existing --noconftest bootstrap. +That known setup failure is retained, not an executed product failure and not +a green whole-streaming claim. No invented ACK/fixture bypass or replay. +Original pytest configuration warnings are retained. + +Source/owner evidence and remaining receiving +--------------------------------------------- + +Complete original AST caller source-family04.py is reused, not copied into a +new scanner. Source-family18 at exact production8c0d9dd37 parses702 OpenHCS, +12 python-introspect,6 registry and17 ArrayBridge modules, zero parse omissions; +24.95s/214992KiB RSS/terminal0. Full related declaration, read/write/import and +MRO sites are retained. Consumer-only76284064d changes no production bytes. +Historical global NRA/R1 OOM remains a limitation, not a clean global proof. +Applicable catalog: IDEN-1/4, BOUND-2, IMPL-2/12. Shared source ABI/schema owners +are consumed directly; no consumer unit/type switch, copied recipe or new roster. + +Original six-production-path R0-17 recorded the added optional-spacing admission. +R0-18 after removing that redundant consumer guard retains all measured +authority/dispatch/raw-read/type-check counts at delta0; class declarations+5 +are the three settings leaves and independent capability/recipe composition, +code+155. Every changed production path is included against integrated main +d5f660082; no detector copy, omitted file, bound increase or positive-count waiver. + +The source-executable pixel workflow is implemented and qualified. Remaining +acceptance is one ordinary whole candidate's registered MCP compile/execution, +persisted unit-bearing CSV/graph ROI readback and native reopen at the original +source coordinates/calibration, plus the ordinary streaming fixture in its +matching environment. Planck received exact published source/ABI and the schema +cutover; parent owns the receiving release. No further Root394 unit-hunk or +localization approval is needed. Active scientific bundles remain immutable. +All original source-check handles17/18/19/20 and AST/R0 handles are terminal; +there is no UNKNOWN operation to replay. + +Final shared-wavelength closure39df6d49c changes only the three shared projection +signatures/local names from pixel_size_um to coordinate_scale. PixelNuclearSettings +already overrides the geometric field declarations; the shared detector must +not falsely name its numeric projection as physical calibration. All production +and current test consumers pass those values positionally; no keyword alias or +fallback remains. Public physical count functions and their physical row fields +retain their meaning. Final affected batch21 is10PASS/110deselected, terminal0, +11.22s/454180KiB RSS/process swaps0: both nuclear/non-nuclear pixel comparisons, +the real pixel CSV/ROI roundtrip and original cooperative wavelength/recipe hooks. +No broader unchanged batch was rerun for that parameter-only closure. + +Byte-exact earlier pixel checkpoint archive +neurite-pixel-route-qualified-20261005.tar.gz, SHA256 +74ddf4b97b84c77ee2f4bf068d28178495dd853c3a4f450a7f7286e5f0f39b53, +contains original17/19/20 negatives as well as18 positives, original R0-17/18, +complete source-family18 and its original parser caller. This archive remains +unchanged; final source21 evidence is published separately. + +Final archive neurite-pixel-route-final-20261005.tar.gz SHA256 +6a21ae33b04ea820f93c6cccee65ba0e577ffe1d6975f80749a0ded6b59ff9bb +contains exact six-path production39df6d49c, current consumer tests and original +source21 controls/AST/R0/parser. AST21 remains702/12/6/17 modules with zero parse +omissions,11.41s/217260KiB RSS/terminal0. R0-21 has the same zero authority-count +deltas, five composed/settings declarations and155 added code lines. No new +detector or ignored production path was introduced. + +Ordinary installed receiving handoff +----------------------------------- + +At the receiving-owner check, Planck's original receiving17/SOURCE-NEXT02.json +already names published541 head1d6673d95 as pending normal whole integration. +It explicitly excludes this branch from main-only build01. No current target +therefore proves541 installed acceptance. Its dependency-artifacts02 receipt +qualifies original PythonIntrospect0.2.1 and ZMQRuntime0.4.1 wheels. The remaining +ObjectState1.3.1 release endpoint returns404; Root's original qualified wheel +and source/SHA handoff was requested in ObjectState25 comment6001052886. +The source runtime difference tag1d36774-to-c75a457 is already determined as +release-script/test-only, not an excuse to invent wheel byte provenance. + +Planck owns the one ordinary whole candidate; Dewey owns exact recorded +native/viewer launch and custody. This is a concrete original dependency +artifact handoff, not a hosted-CI or historical394 implementation hold. No old +16 target overlay, receiver dependency build, current scientific mutation or +second client was dispatched. + +The committed validation/neurite-pixel-public541 packet now provides the tiny +original branched-neuron source generator, complete pixel PipelineDocument and +exact installed/native acceptance relations. It preserves UNKNOWN acquisition +spacing while requiring pixel-labelled analysis rows/edges, all twelve outputs, +source-bearing graph ROI and saved public raw/ROI reopening. Scripts passed +stdlib AST syntax parsing only; neither is represented as installed validation. +Both are unexecuted and require the receiving owner's actual admitted paths. + +The original ACK fixture's dependency closure is also explicit: byte-exact +tests/conftest.py plus three current helper/test modules, staged outside a +source checkout and run against the asserted installed target with conftest +enabled. No stand-in ACK fixture or new protocol is introduced. Native stream +settlement/close ACK remains a separate actual public acceptance, not inferred +from that nominal message-construction fixture. + +All prior source failures/archives remain unchanged. Original source handles +are terminal; no public541 operation or UNKNOWN outcome exists. Production +remains39df6d49c. The next action is Root's original binary handoff to Planck, +then the coalesced whole build/qualification and Dewey's exact released lane; +Singer follows this packet through execution/reopen/ACK and merge without CI. + +Dependency artifact delivered (supersedes the missing-wheel boundary above) +------------------------------------------------------------------------- + +Singer took actual local artifact ownership after the active-claim check found +no deliverer or retained binary. The reused completed ObjectState checkout built +exact v1.3.1 tag1d36774 without isolation/download/source change. Newly qualified +wheel138148B SHA7be55a4b18326709df26b71cd4a8c7ee7456eff1db0e7fd3d676e94c6d9146ca +is published at https://github.com/OpenHCSDev/ObjectState/releases/tag/v1.3.1. +All30 tagged package members/33 hashed RECORD installed bytes match; +215 original source tests and215 installed-origin tests PASS, TwinePASS. +Raw qualification archive SHA4868d1cb240a58dfac79db4acfa94911974ebd918c3892bf40c5cf42236de440 +is published on that release. No nonexistent old-wheel equivalence is claimed. +The package/build/workflow diff to declaredc75a457 is empty; only release-check +script/tests differ. Original queued OIDC/PyPI publisher remains untouched. + +Planck's whole integrationcc1805bfd already includes main2dce374+541e5a7fc7. +The artifact is now handed off on541 comment6001659466 and ObjectState25 +comment6001652650. No ObjectState or hosted-CI hold remains. Actual whole READY +and Dewey's exact released native/viewer/ACK custody are next, before Singer's +installed registered execution/CSV/graph ROI/native saved reopen. No current +scientific input/package or existing failures changed; no public541 call exists. + +Installed original ACK-fixture qualification23 +-------------------------------------------- + +All nine receiving17/attempt02 READY-FREEZE entries verified. Exact candidate +cc1805bfd includes this production; installed OpenHCS/PolyStore/ZMQ origins +were asserted below that target. Original conftest plus three helper/test +modules were extracted byte-exact with git archive from that candidate outside +any source checkout. No fixture copy/reimplementation, installed overlay or +ImageJ download. Standard pytest loaded the ORIGINAL viewer_ack_return_route. + +Initial22 terminated1 BEFORE pytest: source checkout cwd shadowed PYTHONPATH, +and the explicit installed-origin assertion rejected those source imports. +Original stdout/stderr/time remain intact. Distinct23 used the original +installed bootstrap's outside-source cwd, and terminated0:1PASS/54warnings, +7.31s pytest/8.62s total,452720KiB peakRSS/swaps0. Unknown marks/deprecations +remain visible, not suppressed as a fake clean log. No source assertion changed. + +The original test persisted all12 outputs, read all five selected QA files, +and built nominal ACK-addressed streaming messages with exact checkpoint +pixels/channel2/source1.3556 calibration. Its stream sink is the original test +fixture, not a live viewer. This resolves batch20's missing-conftest setup tier; +it does NOT claim native settlement ACK, pixel MCP execution or saved graph +reopening. No native/client/viewer was launched, and both22/23 are terminal. + +Exact raw logs and four original fixture files remain in +engineering-neurite-units-20261003/installed-ack22. Native receiving now needs +Dewey's actual known-terminal lane projection for the prepared public packet; +no package, dependency publication or CI hold remains. Current scientific +endpoints and original frozen inputs remain untouched. diff --git a/docs/validation/neurite-pixel-coordinate-contract-20261003.tar.gz b/docs/validation/neurite-pixel-coordinate-contract-20261003.tar.gz new file mode 100644 index 0000000000..d580931b58 Binary files /dev/null and b/docs/validation/neurite-pixel-coordinate-contract-20261003.tar.gz differ diff --git a/docs/validation/neurite-pixel-route-final-20261005.tar.gz b/docs/validation/neurite-pixel-route-final-20261005.tar.gz new file mode 100644 index 0000000000..e8c6a709dd Binary files /dev/null and b/docs/validation/neurite-pixel-route-final-20261005.tar.gz differ diff --git a/docs/validation/neurite-pixel-route-qualified-20261005.tar.gz b/docs/validation/neurite-pixel-route-qualified-20261005.tar.gz new file mode 100644 index 0000000000..e525375ada Binary files /dev/null and b/docs/validation/neurite-pixel-route-qualified-20261005.tar.gz differ diff --git a/docs/validation/neurite-public-pixel-20261005.tar.gz b/docs/validation/neurite-public-pixel-20261005.tar.gz new file mode 100644 index 0000000000..0a70ceb9ef Binary files /dev/null and b/docs/validation/neurite-public-pixel-20261005.tar.gz differ diff --git a/docs/validation/neurite-public-pixel-acceptance-20261005.rst b/docs/validation/neurite-public-pixel-acceptance-20261005.rst new file mode 100644 index 0000000000..b114ccc6e6 --- /dev/null +++ b/docs/validation/neurite-public-pixel-acceptance-20261005.rst @@ -0,0 +1,121 @@ +#541 installed public pixel-space acceptance +=========================================== + +Singer exercised the exact Dewey-released ADMIN541_PUBLIC17_88 once through +the original recorded-mcp owner. Receiving17 attempt02 is immutable source +cc1805bfd6526f9093ec80c4cf1f532245988ab9; #541 production is +39df6d49c34dd9efebf4b22e877e195c32052310. No scientific bundle was changed. + +Actual user path +---------------- + +The sole CLI handle was 2961. Native 1358914/create1791230257.99 on 6020 TCP +became READY through its original owned observation, then explicit catalog +preparation selected this endpoint. The complete path-only projected source +document pipeline-public01.py used SOURCE_BINDINGS with empty acquisition +spacing, no physical pixel-size artifact, and the registered +neurite_outgrowth_metaxpress_pixels declaration. + +Artifact plan passed; session-1 compiled as job-1, execution +2dcd29cd-75b1-4f93-a589-95f426884727, COMPLETE. Execution job-2, +240582cd-78b7-4033-a8c8-9ebc7d378690, COMPLETE with no execution errors. +The original values export is at the admitted HDD destination, values01. +ZMQRuntimeExecutionObservationExport.read().require_valid_observation() +accepts the original installed export: 12 artifacts on A01, three graph edges, +two cell rows and one summary row. Analysis coordinate_spacing.unit is PIXELS; +inherited acquisition source_voxel_spacing has no values. Both details CSVs +declare coordinate_unit=pixels and have no misleading _um columns. All three +persisted graph ROI feature records declare pixels; their original bound +acquisition metadata retains empty spacing. No SWC was written. + +Public inventory resolved the actual saved graph archive. The public stream +reopened it as three feature-bearing native Shapes paths with 69 coordinates, +channel 2/site 1/Z 1/time 1/A01, pending_update=false. The independently saved +128x128 uint16 source image reopened through the same public inventory/stream +owner. Its summary reports 396 nonzero pixels, maximum 1000. Matched raw, +graph-only and combined render-complete screenshots were personally opened: +the three graph paths overlay their synthetic raw supports. This verifies +engineering geometry/presentation, not biological accuracy or physical units. + +Unit/presentation qualification +------------------------------- + +Native transforms use scale 1. Viewer payload summaries report these display +unit values as micrometers, although the saved graph acquisition spacing is +empty. This readback does NOT establish physical calibration and is not used +for the pixel metrics. The numerical graph metric, CSV and ROI features remain +explicitly pixels. The physical route/physical SWC guards are unchanged. This +qualification must remain visible; it does not negate the demonstrated pixel +execute/persist/reopen workflow. + +Exact existing owner trace: SpatialGraph.coordinate_spacing owns the analysis +metric; inherited SourceVoxelSpacingFields owns acquisition spacing. +SpatialGraphROI materialization binds graph.source_voxel_spacing, not the +analysis metric, through original ImagePayloadMetadata/ROIArchiveSourceMetadata. +SourceVoxelSpacing.has_values is false for the empty tuple regardless of its +default enum tag. Thus the archive contains no calibrated coordinates. +On reopen ViewerStreamingSource.calibrated_metadata fills absent spacing from +plate_image_metadata; the metadata handler's source_voxel_spacing projects +get_pixel_size into a MICROMETERS tuple. This separate legacy numeric-default +projection explains the native summary and needs distinct source-owner review, +not changes to pixel graph metrics or a fabricated acquisition calibration. +Singer retains that follow-up boundary; it is not a pixel-route merge gate. + +Parent independently opened these exact three original captures and checked +the two cell CSV lengths 23 and 59.982756, consolidated PrimaryCoordinateUnit +pixels and all three ROI pixel-unit features. The supported visual claim is +local alignment of synthetic horizontal/diagonal paths, not complete tracing +of every fixture support or biological success. + +Known negatives and exact closure +-------------------------------- + +Original help/session --help shell parser errors, an undeclared short tool +name, and a snapshot request with output_directory instead of the declared +output_dir_path are retained. Those requests did not submit pipeline execution +or replay an UNKNOWN. Correctly declared subsequent requests succeeded. +verify-public01 failed because the receiving checker queried nonexistent +SpatialGraph.coordinate_unit; verify-public02 uses the actual owner +coordinate_spacing.unit and passes. Both originals are retained unchanged. + +Viewer 1371582/create1791230595.53: public close acknowledged, endpoint +terminated, process_exited and succeeded all true. Native 1358914: original +owned close likewise returned all five true. One exit was sent to original +CLI2961, which terminated with numeric exit 2; this is retained separately +from successful native/tool outcomes and includes the known parser failures. +Independent ps shows client1355894/native1358914/viewer1371582 absent; TCP +6020/6021/7020/7021 listeners absent. No viewer was abandoned, no job replayed. +The original display helpers remain with Dewey; lane88 is returned for his +next separately recorded workflow. Science89/94/96 was untouched. + +Original evidence +----------------- + +Control root: +/home/ts/wt/openhcs-issue-batch-20260929/engineering541/public88-receiving17 + +Full journals are ADMIN541_PUBLIC17_88/author-workspace/output/runtime/ +mcp.stdin, mcp.stdout and mcp.timing. verify-public01/02 scripts and original +stdout/stderr are siblings of this receipt. pipeline-public01.py is in that +author-workspace/output. Native/viewer logs remain in output/runtime/data/ +openhcs/logs. The original operation and helper custody handoff is READY.rst +and #541 comment6001870666; READY-FREEZE.sha256 passed before launch. + +HDD artifact root: +/run/media/ts/hdd/openhcs-engineering/engineering541-public88-20261005 + +CSV/ROI artifacts: results/acquisition_openhcs/results. +VALUES SHA256: 0849d1ad8b65d4be8c600073aa3e80f6b56720667d62766dec58a7f153fd799f. +Graph archive SHA256: b931643373add3b81fd9598765985f3d95a4745cf8c7690b6b3084f2993dd312. +Original raw SHA256: 3d33ce1805136d105973a36d961a6a844d904fa10e414293d5a8446f345975ab. + +Personally opened original captures: +captures/raw/20261005T200517067165Z_napari_6021_OpenHCS_Napari_Visualization.png +captures/result/20261005T200538465883Z_napari_6021_OpenHCS_Napari_Visualization.png +captures/combined/20261005T200421506476Z_napari_6021_OpenHCS_Napari_Visualization.png + +Source owner remains the existing engine profile/coordinate capability and +typed SourceVoxelSpacing/SpatialGraph/materializer family; receiving added no +algorithm, unit mirror, calibration alias or codec. Original source and +installed source-fixture controls are not rerun for this receiving receipt. + diff --git a/openhcs/core/artifacts.py b/openhcs/core/artifacts.py index 14544383ed..5b3f44b7e6 100644 --- a/openhcs/core/artifacts.py +++ b/openhcs/core/artifacts.py @@ -1767,15 +1767,7 @@ def contextualize_output( ) if output_plan is not None: output_value.validate_artifact_name(output_plan.name) - source_provenance = output_value.contextualized_source_provenance( - image_payload_metadata(source_payload).source_provenance - ) - contextualized_provenance = output_value.source_provenance.with_missing_from( - source_provenance - ) - if contextualized_provenance == output_value.source_provenance: - return output_value - return replace(output_value, source_provenance=contextualized_provenance) + return output_value.contextualized_source_metadata(image_payload_metadata(source_payload)) @classmethod def contextualize_output_from_projector( diff --git a/openhcs/core/runtime_spatial_graph.py b/openhcs/core/runtime_spatial_graph.py index db26f65412..96513de62c 100644 --- a/openhcs/core/runtime_spatial_graph.py +++ b/openhcs/core/runtime_spatial_graph.py @@ -6,16 +6,19 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import ClassVar, Self +from typing import ClassVar, Self, TYPE_CHECKING import numpy as np from openhcs.core.artifacts import NamedArtifactPayload +from openhcs.core.source_metadata import SourceVoxelSpacing, SourceVoxelSpacingFields from openhcs.core.source_image_provenance import ( - SourceImageProvenance, SourceImageProvenanceFields, ) +if TYPE_CHECKING: + from openhcs.core.runtime_image_values import ImagePayloadMetadata + SpatialGraphFeatureValue = str | int | float | bool | None SpatialCoordinate = tuple[float, ...] @@ -56,7 +59,7 @@ def _validated_features( @dataclass(frozen=True, slots=True) class SpatialGraphNode: - """One spatial topology node with scalar features and a physical radius.""" + """One topology node with features and radius in its graph's analysis unit.""" node_id: int coordinates: SpatialCoordinate @@ -188,7 +191,7 @@ def feature_mapping(self) -> dict[str, SpatialGraphFeatureValue]: @dataclass(slots=True) -class SpatialGraph(SourceImageProvenanceFields, NamedArtifactPayload): +class SpatialGraph(SourceImageProvenanceFields, SourceVoxelSpacingFields, NamedArtifactPayload): """Named spatial graph with direct node references and path geometry.""" SWC_TYPE_FEATURE: ClassVar[str] = "swc_type" @@ -197,12 +200,18 @@ class SpatialGraph(SourceImageProvenanceFields, NamedArtifactPayload): name: str nodes: tuple[SpatialGraphNode, ...] edges: tuple[SpatialGraphEdge, ...] - coordinate_spacing: SpatialCoordinate = (1.0, 1.0) + coordinate_spacing: SourceVoxelSpacing + """Analysis metric per source-pixel coordinate; distinct from acquisition calibration. + + Node radii and dimensional feature values use this metric's declared unit. + The inherited source_voxel_spacing retains acquisition/native coordinates. + """ source_plane_index: int | None = None def __post_init__(self, *source_provenance_values: object) -> None: self.absorb_explicit_source_provenance(source_provenance_values) self.normalize_source_provenance_fields() + self.normalize_source_voxel_spacing_fields() self.validate_artifact_name() nodes = tuple(self.nodes) edges = tuple(self.edges) @@ -216,15 +225,11 @@ def __post_init__(self, *source_provenance_values: object) -> None: if len(set(edge_ids)) != len(edge_ids): raise ValueError("SpatialGraph edge IDs must be unique.") - spacing = tuple(float(value) for value in self.coordinate_spacing) + spacing = self.coordinate_spacing.values_zyx if len(spacing) not in (2, 3): raise ValueError( "SpatialGraph.coordinate_spacing must contain two or three values." ) - if not np.all(np.isfinite(spacing)) or any(value <= 0 for value in spacing): - raise ValueError( - "SpatialGraph.coordinate_spacing must contain finite positive values." - ) if any(len(node.coordinates) != len(spacing) for node in nodes): raise ValueError( "Every SpatialGraphNode dimensionality must match coordinate_spacing." @@ -243,7 +248,6 @@ def __post_init__(self, *source_provenance_values: object) -> None: raise ValueError( "Every SpatialGraphEdge dimensionality must match coordinate_spacing." ) - object.__setattr__(self, "coordinate_spacing", spacing) if self.source_plane_index is not None: if isinstance(self.source_plane_index, bool) or not isinstance( self.source_plane_index, @@ -254,15 +258,15 @@ def __post_init__(self, *source_provenance_values: object) -> None: raise ValueError("SpatialGraph.source_plane_index cannot be negative") object.__setattr__(self, "source_plane_index", int(self.source_plane_index)) - def contextualized_source_provenance( - self, - source_provenance: SourceImageProvenance, - ) -> SourceImageProvenance: - """Project invocation provenance through the graph's declared source plane.""" - - if self.source_plane_index is None: - return source_provenance - return source_provenance.for_source_plane(self.source_plane_index) + def contextualized_source_metadata(self, metadata: ImagePayloadMetadata) -> Self: + """Bind acquisition provenance/calibration without altering the analysis metric.""" + if self.source_plane_index is not None: + metadata = metadata.for_source_plane(self.source_plane_index) + provenance = self.source_provenance.with_missing_from(metadata.source_provenance) + spacing = self.source_voxel_spacing.with_missing_from(metadata.source_voxel_spacing) + if provenance == self.source_provenance and spacing == self.source_voxel_spacing: + return self + return self.replace_fields(source_provenance=provenance, source_voxel_spacing=spacing) @classmethod def from_swc(cls, path: str | Path) -> Self: @@ -356,7 +360,7 @@ def from_swc(cls, path: str | Path) -> Self: name=graph_name, nodes=nodes, edges=tuple(edges), - coordinate_spacing=(1.0, 1.0, 1.0), + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0, 1.0)), source_path=str(source_path), ) graph.require_directed_forest() diff --git a/openhcs/core/source_metadata.py b/openhcs/core/source_metadata.py index 4a330990cb..f893373bb4 100644 --- a/openhcs/core/source_metadata.py +++ b/openhcs/core/source_metadata.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, field -from enum import Enum +from enum import Enum, StrEnum from functools import lru_cache from math import isfinite from pathlib import Path @@ -841,7 +841,7 @@ def merge_into( ) -class SourceVoxelSpacingUnit(Enum): +class SourceVoxelSpacingUnit(StrEnum): """Coordinate units own their projection into physical scalar calibration.""" native_unit: str @@ -850,24 +850,32 @@ class SourceVoxelSpacingUnit(Enum): "micrometers", lambda spacing: spacing.isotropic_xy_spacing, "micrometer", + lambda spacing: spacing.values_zyx, ) - RELATIVE = "relative", lambda spacing: None, "dimensionless" + RELATIVE = "relative", lambda spacing: None, "dimensionless", lambda spacing: None + PIXELS = "pixels", lambda spacing: None, "pixel", lambda spacing: None def __new__( cls, name: str, physical_projection: Callable[["SourceVoxelSpacing"], float | None], native_unit: str, + physical_coordinates: Callable[["SourceVoxelSpacing"], tuple[float, ...] | None], ): - member = object.__new__(cls) + member = str.__new__(cls, name) member._value_ = name member._physical_projection = physical_projection member.native_unit = native_unit + member._physical_coordinates = physical_coordinates return member def physical_pixel_size(self, spacing: "SourceVoxelSpacing") -> float | None: return self._physical_projection(spacing) + def physical_coordinates(self, spacing: "SourceVoxelSpacing") -> tuple[float, ...] | None: + """Project coordinates only when this declaration establishes physical units.""" + return self._physical_coordinates(spacing) + @dataclass(frozen=True, slots=True) class SourceVoxelSpacing: @@ -889,6 +897,8 @@ class SourceVoxelSpacing: coordinate ratios, including CellProfiler spacing normalized by Y. Relative spacing and legacy spacing metadata without recorded units do not provide physical scalar calibration. An empty values tuple leaves spacing unspecified. + PIXELS explicitly measures source-pixel coordinates; it does not establish + physical calibration or authorize physical coordinate exports. """ def __post_init__(self) -> None: @@ -1004,6 +1014,16 @@ def require_physical_pixel_size( ) return value + def require_physical_coordinates(self) -> tuple[float, ...]: + """Admit physical exports without treating pixel or relative units as micrometers.""" + coordinates = self.unit.physical_coordinates(self) + if not coordinates: + raise ValueError( + "Physical coordinate export requires explicit micrometer spacing; " + "pixel and relative analysis coordinates cannot provide it." + ) + return coordinates + @classmethod def resolve_physical_pixel_size( cls, diff --git a/openhcs/processing/backends/analysis/count_cells_simple.py b/openhcs/processing/backends/analysis/count_cells_simple.py index 1f8a06ccb8..54e19d1df9 100644 --- a/openhcs/processing/backends/analysis/count_cells_simple.py +++ b/openhcs/processing/backends/analysis/count_cells_simple.py @@ -154,6 +154,14 @@ class MetaXpressWavelengthSettings: intensity_above_local_background: float = 100.0 """Minimum raw-intensity difference above adaptive local background.""" + def minimum_width_px(self, coordinate_scale: float) -> float: + """Project this wavelength's lower width into image pixels.""" + return self.approx_min_width / coordinate_scale + + def maximum_width_px(self, coordinate_scale: float) -> float: + """Project this wavelength's upper width into image pixels.""" + return self.approx_max_width / coordinate_scale + def validate(self, name: str) -> None: """Validate one wavelength's complete public settings block.""" @@ -579,23 +587,23 @@ def accepted_labels(self) -> np.ndarray: def segment_metaxpress_round_objects( slice_data: np.ndarray, settings: MetaXpressWavelengthSettings, - pixel_size_um: float, + coordinate_scale: float, ) -> np.ndarray: """Segment bright round objects using shared MetaXpress-style controls.""" return round_object_segmentation_stages( - slice_data, settings, pixel_size_um + slice_data, settings, coordinate_scale ).accepted_labels def round_object_segmentation_stages( slice_data: np.ndarray, settings: MetaXpressWavelengthSettings, - pixel_size_um: float, + coordinate_scale: float, ) -> RoundObjectSegmentationStages: """Run the shared detector once and retain its exact acceptance evidence.""" - min_width_px = settings.approx_min_width / pixel_size_um - max_width_px = settings.approx_max_width / pixel_size_um + min_width_px = settings.minimum_width_px(coordinate_scale) + max_width_px = settings.maximum_width_px(coordinate_scale) intensity_above_background = local_background_response( slice_data, object_width_px=max_width_px, diff --git a/openhcs/processing/backends/analysis/neurite_outgrowth.py b/openhcs/processing/backends/analysis/neurite_outgrowth.py index 02bc7ae043..ef3983835d 100644 --- a/openhcs/processing/backends/analysis/neurite_outgrowth.py +++ b/openhcs/processing/backends/analysis/neurite_outgrowth.py @@ -8,7 +8,7 @@ import heapq from collections import Counter, defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum from itertools import combinations from typing import Iterable, Mapping, Sequence @@ -57,6 +57,7 @@ SpatialGraphEdge, SpatialGraphNode, ) +from openhcs.core.source_metadata import SourceVoxelSpacing, SourceVoxelSpacingUnit from openhcs.processing.materialization import ( CsvOptions, ImageFileOptions, @@ -211,6 +212,465 @@ def secondary_kwargs( "discard_edge_objects": False, } + @classmethod + def morphology_output(cls) -> ArtifactSpec: + """Declare the metric-compatible materialization at the recipe owner.""" + return NEURITE_MORPHOLOGY_OUTPUT + + @classmethod + def artifact_outputs(cls) -> tuple[ArtifactSpec, ...]: + """Derive both callable declarations from one owned output family.""" + return ( + NEURITE_SUMMARY_OUTPUT, + NEURITE_CELLS_OUTPUT, + CELL_BODIES_OUTPUT, + NEURITE_LABELS_OUTPUT, + UNIFIED_NEURONS_OUTPUT, + NUCLEI_OUTPUT, + NEURITE_CANDIDATE_MASK_OUTPUT, + NEURITE_UNROOTED_RESIDUAL_OUTPUT, + NEURITE_SECONDARY_OWNERSHIP_OUTPUT, + NEURITE_TOPOLOGY_DROPPED_TRACE_OUTPUT, + NEURITE_TOPOLOGY_ADDED_TRACE_OUTPUT, + cls.morphology_output(), + ) + + def analyze( + self, + image, + *, + neurite_channel_index: int, + illumination: NeuriteIllumination, + cell_body: "MetaXpressCellBodySettings", + outgrowth: "MetaXpressOutgrowthSettings", + use_nuclear_stain: bool, + nuclear_stain: "MetaXpressNuclearSettings", + coordinate_spacing: SourceVoxelSpacing, + ) -> tuple: + """One recipe with isotropic XY metrics supplied by the callable declaration.""" + image_array = np.asarray(image) + if image_array.ndim != 3: + raise ValueError( + f"Expected a 2D channel stack with shape (C, Y, X), got " + f"shape {image_array.shape}" + ) + if not 0 <= neurite_channel_index < image_array.shape[0]: + raise ValueError("neurite_channel_index is outside the input stack") + + illumination = NeuriteIllumination(illumination) + cell_body.validate() + outgrowth.validate() + coordinate_scale = coordinate_spacing.isotropic_xy_spacing + + body_channel_index = ( + neurite_channel_index + if cell_body.channel_index is None + else int(cell_body.channel_index) + ) + if not 0 <= body_channel_index < image_array.shape[0]: + raise ValueError("cell_body.channel_index is outside the input stack") + + nuclei_labels = np.zeros(image_array.shape[1:], dtype=np.int32) + if use_nuclear_stain: + nuclear_stain.validate("nuclear_stain") + if not 0 <= nuclear_stain.channel_index < image_array.shape[0]: + raise ValueError("nuclear_stain.channel_index is outside the input stack") + if nuclear_stain.channel_index == neurite_channel_index: + raise ValueError( + "nuclear_stain.channel_index must differ from neurite_channel_index" + ) + nuclei_labels = segment_metaxpress_round_objects( + image_array[nuclear_stain.channel_index], + nuclear_stain, + coordinate_scale, + ) + + bright_objects = illumination == NeuriteIllumination.FLUORESCENCE + nuclear_seeded_signal_body_mode = ( + use_nuclear_stain and body_channel_index == neurite_channel_index + ) + body_detection_channel_index = ( + int(nuclear_stain.channel_index) + if nuclear_seeded_signal_body_mode + else body_channel_index + ) + body_image = image_array[body_detection_channel_index] + neurite_image = image_array[neurite_channel_index] + if nuclear_seeded_signal_body_mode: + signal_cell_bodies = _derive_signal_cell_bodies( + nuclei_labels, + neurite_image, + cell_body, + coordinate_scale, + bright_objects=bright_objects, + ) + keep_signal_body = ( + np.bincount( + signal_cell_bodies.ravel(), + minlength=int(nuclei_labels.max()) + 1, + ) + > 0 + ) + keep_signal_body[0] = False + cell_body_labels = _relabel(signal_cell_bodies, keep_signal_body) + cell_body_payload = SourceImageObjectLabelBuildRequest( + image=neurite_image, + labels=cell_body_labels, + ).payload() + else: + cell_body_payload = _identify_cell_bodies_cellprofiler( + body_image, + cell_body, + coordinate_scale, + bright_objects=bright_objects, + nuclei_labels=nuclei_labels if use_nuclear_stain else None, + ) + cell_body_labels = object_label_dense_array( + cell_body_payload, + dtype=np.int32, + ) + + outgrowth_binary, outgrowth_skeleton, outgrowth_response = ( + _identify_neurites_cellprofiler( + neurite_image, + cell_body, + outgrowth, + coordinate_scale, + bright_objects=bright_objects, + ) + ) + outgrowth_width_px = outgrowth.maximum_width_px(coordinate_scale) + nuclear_seed_mode = use_nuclear_stain and body_detection_channel_index == int( + nuclear_stain.channel_index + ) + if nuclear_seeded_signal_body_mode: + secondary_owner_regions = _propagate_neurite_owner_regions( + outgrowth_response, + cell_body_labels, + minimum_response=outgrowth.intensity_above_local_background, + ) + else: + secondary_owner_regions = _identify_secondary_owner_regions_cellprofiler( + neurite_image, + cell_body_payload, + body_width_px=cell_body.maximum_width_px(coordinate_scale), + bright_objects=bright_objects, + ) + if nuclear_seed_mode and not nuclear_seeded_signal_body_mode: + cell_body_payload = _qualify_nuclear_cell_bodies( + cell_body_payload, + cell_body_labels, + secondary_owner_regions, + ) + cell_body_labels = object_label_dense_array( + cell_body_payload, + dtype=np.int32, + ) + secondary_owner_regions = _identify_secondary_owner_regions_cellprofiler( + neurite_image, + cell_body_payload, + body_width_px=cell_body.maximum_width_px(coordinate_scale), + bright_objects=bright_objects, + ) + + topology = _analyze_topology( + outgrowth_skeleton, + cell_body_labels, + coordinate_scale, + outgrowth_width_px, + ) + owner_skeleton = _render_owned_skeleton( + outgrowth_skeleton.shape, + topology, + ) + initial_topology_owned_trace_pixels = int( + np.count_nonzero((owner_skeleton > 0) & (cell_body_labels == 0)) + ) + if nuclear_seed_mode: + owner_skeleton = _adopt_secondary_owned_path_segments( + topology, + owner_skeleton, + secondary_owner_regions, + ) + secondary_adopted_trace_pixels = int( + np.count_nonzero((owner_skeleton > 0) & (cell_body_labels == 0)) + ) + crossing_support = _render_crossing_support( + outgrowth_skeleton.shape, + topology, + ) + crossing_core_mask = _render_crossing_core_mask( + outgrowth_skeleton.shape, + topology, + ) + resolved_crossovers = _count_multi_owner_crossings( + crossing_core_mask, + crossing_support, + ) + crossing_core_support = np.where( + crossing_core_mask, + crossing_support, + 0, + ).astype(np.int32, copy=False) + owner_skeleton = _repair_signal_supported_skeleton( + owner_skeleton, + outgrowth_response, + secondary_owner_regions, + cell_body_labels, + minimum_response=outgrowth.intensity_above_local_background, + ) + owner_skeleton = np.where( + crossing_support > 0, + crossing_support, + owner_skeleton, + ).astype(np.int32, copy=False) + signal_repaired_trace_pixels = int( + np.count_nonzero((owner_skeleton > 0) & (cell_body_labels == 0)) + ) + pre_topology_owner_skeleton = owner_skeleton.copy() + pre_topology_owner_skeleton[cell_body_labels > 0] = 0 + topology = _analyze_owned_topology( + pre_topology_owner_skeleton, + cell_body_labels, + coordinate_scale, + outgrowth_width_px, + shared_crossing_mask=crossing_core_mask, + ) + neurite_skeleton = _render_owned_skeleton( + pre_topology_owner_skeleton.shape, + topology, + ) + topology_path_mask = _render_topology_path_mask( + pre_topology_owner_skeleton.shape, + topology, + ) + physically_soma_rooted_trace = _physically_soma_rooted_owner_mask( + pre_topology_owner_skeleton, + cell_body_labels, + maximum_root_distance=max(1, int(np.ceil(outgrowth_width_px)) + 2), + ) + topology_dropped_trace = ( + (pre_topology_owner_skeleton > 0) + & (neurite_skeleton == 0) + & ~crossing_core_mask + ) + topology_added_trace = (neurite_skeleton > 0) & (pre_topology_owner_skeleton == 0) + final_topology_owned_trace_pixels = int(np.count_nonzero(neurite_skeleton)) + # A physical crossing core supports two logical paths, but an object-label + # raster can store only one identity per pixel. The topology above remains + # authoritative for both neurites; publish the already-resolved nearest + # owner for each shared physical core pixel so QA and raster expansion do + # not misclassify valid crossover signal as an unrooted residual. + neurite_skeleton = np.where( + crossing_core_support > 0, + crossing_core_support, + neurite_skeleton, + ).astype(np.int32, copy=False) + owner_outgrowth = _expand_skeleton_ownership( + neurite_skeleton, + outgrowth_binary, + outgrowth_width_px, + ) + owner_outgrowth[cell_body_labels > 0] = 0 + # Secondary propagation supplies foreground evidence during detection; + # final neuron ownership comes from the same rooted topology as its traces + # and measurements. Publishing the earlier propagation would reassign + # crossing arms independently of that topology. + unified_neuron_labels = np.where( + cell_body_labels > 0, + cell_body_labels, + owner_outgrowth, + ).astype(np.int32, copy=False) + + candidate_neurite_mask = outgrowth_binary & (cell_body_labels == 0) + candidate_trace_mask = outgrowth_skeleton & (cell_body_labels == 0) + rooted_mask = owner_outgrowth > 0 + rooted_trace_mask = neurite_skeleton > 0 + unrooted_residual = candidate_neurite_mask & ~rooted_mask + secondary_owned_residual = unrooted_residual & (secondary_owner_regions > 0) + candidate_trace_pixels = int(np.count_nonzero(candidate_trace_mask)) + rooted_candidate_trace_pixels = int( + np.count_nonzero(candidate_trace_mask & rooted_trace_mask) + ) + secondary_owned_unrooted_trace_pixels = int( + np.count_nonzero( + candidate_trace_mask & ~rooted_trace_mask & (secondary_owner_regions > 0) + ) + ) + candidate_mask_pixels = int(np.count_nonzero(candidate_neurite_mask)) + rooted_candidate_mask_pixels = int( + np.count_nonzero(candidate_neurite_mask & rooted_mask) + ) + unrooted_residual_pixels = int(np.count_nonzero(unrooted_residual)) + secondary_owned_residual_pixels = int(np.count_nonzero(secondary_owned_residual)) + + cell_results = _build_cell_results( + cell_body_labels, + owner_outgrowth, + neurite_image, + topology, + outgrowth.minimum_cell_growth_to_log_as_significant, + coordinate_spacing, + slice_index=body_channel_index, + ) + summary = _build_summary( + cell_results, + coordinate_unit=coordinate_spacing.unit, + neurite_channel_index=neurite_channel_index, + cell_body_channel_index=body_channel_index, + nuclear_channel_index=( + nuclear_stain.channel_index if use_nuclear_stain else -1 + ), + resolved_crossovers=resolved_crossovers, + mean_outgrowth_average_intensity=( + float(np.mean(neurite_image[owner_outgrowth > 0])) + if np.any(owner_outgrowth > 0) + else 0.0 + ), + candidate_trace_pixels=candidate_trace_pixels, + rooted_candidate_trace_pixels=rooted_candidate_trace_pixels, + unrooted_candidate_trace_pixels=( + candidate_trace_pixels - rooted_candidate_trace_pixels + ), + rooted_candidate_trace_yield=( + rooted_candidate_trace_pixels / candidate_trace_pixels + if candidate_trace_pixels + else 0.0 + ), + initial_topology_owned_trace_pixels=initial_topology_owned_trace_pixels, + secondary_adopted_trace_pixels=secondary_adopted_trace_pixels, + signal_repaired_trace_pixels=signal_repaired_trace_pixels, + crossing_core_trace_pixels=int(np.count_nonzero(crossing_core_mask)), + final_topology_dropped_trace_pixels=int( + np.count_nonzero(topology_dropped_trace) + ), + final_topology_dropped_crossing_support_trace_pixels=int( + np.count_nonzero(topology_dropped_trace & (crossing_support > 0)) + ), + final_topology_dropped_unrooted_path_trace_pixels=int( + np.count_nonzero(topology_dropped_trace & topology_path_mask) + ), + final_topology_dropped_physically_rooted_path_trace_pixels=int( + np.count_nonzero( + topology_dropped_trace + & topology_path_mask + & physically_soma_rooted_trace + ) + ), + final_topology_dropped_physically_unrooted_path_trace_pixels=int( + np.count_nonzero( + topology_dropped_trace + & topology_path_mask + & ~physically_soma_rooted_trace + ) + ), + final_topology_dropped_unrepresented_trace_pixels=int( + np.count_nonzero(topology_dropped_trace & ~topology_path_mask) + ), + final_topology_added_trace_pixels=int(np.count_nonzero(topology_added_trace)), + final_topology_owned_trace_pixels=final_topology_owned_trace_pixels, + published_owned_trace_pixels=int(np.count_nonzero(rooted_trace_mask)), + secondary_owned_unrooted_trace_pixels=(secondary_owned_unrooted_trace_pixels), + secondary_unowned_unrooted_trace_pixels=( + candidate_trace_pixels + - rooted_candidate_trace_pixels + - secondary_owned_unrooted_trace_pixels + ), + candidate_mask_pixels=candidate_mask_pixels, + rooted_candidate_mask_pixels=rooted_candidate_mask_pixels, + unrooted_residual_pixels=unrooted_residual_pixels, + secondary_owned_residual_pixels=secondary_owned_residual_pixels, + secondary_unowned_residual_pixels=( + unrooted_residual_pixels - secondary_owned_residual_pixels + ), + secondary_owned_residual_fraction=( + secondary_owned_residual_pixels / unrooted_residual_pixels + if unrooted_residual_pixels + else 0.0 + ), + ) + + cell_body_stack = np.zeros(image_array.shape, dtype=np.int32) + cell_body_stack[body_channel_index] = cell_body_labels + neurite_stack = np.zeros(image_array.shape, dtype=np.int32) + neurite_stack[neurite_channel_index] = neurite_skeleton + unified_neuron_stack = np.zeros(image_array.shape, dtype=np.int32) + unified_neuron_stack[neurite_channel_index] = unified_neuron_labels + nuclei_stack = np.zeros(image_array.shape, dtype=np.int32) + if use_nuclear_stain: + nuclei_stack[nuclear_stain.channel_index] = nuclei_labels + neurite_morphology = _build_neurite_morphology_graph( + topology, + cell_body_labels, + coordinate_spacing=coordinate_spacing, + outgrowth_width_px=outgrowth_width_px, + ) + neurite_morphology = neurite_morphology.replace_fields( + source_plane_index=neurite_channel_index + ) + return ( + image, + DataclassMeasurementColumnarRows( + (summary,), + row_type=NeuriteOutgrowthSummary, + ), + DataclassMeasurementColumnarRows( + tuple(cell_results), + row_type=NeuriteOutgrowthCellResult, + ), + cell_body_stack, + neurite_stack, + unified_neuron_stack, + nuclei_stack, + SelectedPlaneImageOutput( + outgrowth_binary.astype(np.uint8, copy=False)[None], + (neurite_channel_index,), + ), + SelectedPlaneImageOutput( + unrooted_residual.astype(np.uint8, copy=False)[None], + (neurite_channel_index,), + ), + SelectedPlaneImageOutput( + secondary_owner_regions.astype(np.int32, copy=False)[None], + (neurite_channel_index,), + ), + SelectedPlaneImageOutput( + topology_dropped_trace.astype(np.uint8, copy=False)[None], + (neurite_channel_index,), + ), + SelectedPlaneImageOutput( + topology_added_trace.astype(np.uint8, copy=False)[None], + (neurite_channel_index,), + ), + neurite_morphology, + ) + + + +class PixelMetricCoordinates: + """Independent pixel-analysis/export capability composed with the CP recipe.""" + + @staticmethod + def analysis_spacing() -> SourceVoxelSpacing: + return SourceVoxelSpacing((1.0, 1.0), unit=SourceVoxelSpacingUnit.PIXELS) + + @classmethod + def morphology_output(cls) -> ArtifactSpec: + return replace( + super().morphology_output(), + materialization=MaterializationSpec(SpatialGraphROIOptions()), + ) + + +@dataclass(frozen=True) +class PixelCellProfilerNeuriteEngineProfile( + PixelMetricCoordinates, CellProfilerNeuriteEngineProfile +): + """C3 MRO composes coordinate/export policy with the unchanged CP algorithm.""" + + +PIXEL_CELLPROFILER_NEURITE_ENGINE_PROFILE = PixelCellProfilerNeuriteEngineProfile() + CELLPROFILER_NEURITE_ENGINE_PROFILE = CellProfilerNeuriteEngineProfile() @@ -283,19 +743,27 @@ def validate(self) -> None: ): raise ValueError("cell_body.channel_index must be a non-negative integer") + def maximum_width_px(self, coordinate_scale: float) -> float: + """Project this declaration's maximum body width into image pixels.""" + return self.approximate_max_width / coordinate_scale + + def minimum_area_px(self, coordinate_scale: float) -> float: + """Project this declaration's minimum body area into image pixels.""" + return self.minimum_area / coordinate_scale**2 + def contract_candidates( self, labels: np.ndarray, response: np.ndarray, - pixel_size_um: float, + coordinate_scale: float, ) -> np.ndarray: """Apply this declaration's calibrated and pixel-unit soma gates.""" return _cell_body_contract_candidates( labels, response, - minimum_area_px=self.minimum_area / pixel_size_um**2, + minimum_area_px=self.minimum_area_px(coordinate_scale), minimum_inscribed_diameter_px=self.minimum_inscribed_diameter_px, - maximum_width_px=self.approximate_max_width / pixel_size_um, + maximum_width_px=self.maximum_width_px(coordinate_scale), intensity_threshold=self.intensity_above_local_background, ) @@ -339,6 +807,10 @@ class MetaXpressOutgrowthSettings: candidate_hysteresis_seed_correction_factor: float | None = None """Optional stricter seed threshold retaining connected dim candidates.""" + def maximum_width_px(self, coordinate_scale: float) -> float: + """Project this declaration's outgrowth width into image pixels.""" + return self.maximum_width / coordinate_scale + def validate(self) -> None: if not np.isfinite(self.maximum_width) or self.maximum_width <= 0: raise ValueError("outgrowth.maximum_width must be > 0") @@ -382,6 +854,41 @@ class MetaXpressNuclearSettings(MetaXpressWavelengthSettings): channel_index: int = 1 +@dataclass(frozen=True) +class PixelCellBodySettings(MetaXpressCellBodySettings): + """Original body detection controls, with geometry explicitly in pixels.""" + + approximate_max_width: float = MetaXpressCellBodySettings().approximate_max_width + """Maximum short-axis width in pixels; also sets neighbourhood/growth scales.""" + + minimum_area: float = MetaXpressCellBodySettings().minimum_area + """Minimum admitted candidate area in square pixels, after detection/growth.""" + + +@dataclass(frozen=True) +class PixelOutgrowthSettings(MetaXpressOutgrowthSettings): + """Original outgrowth controls, with geometry explicitly in pixels.""" + + maximum_width: float = MetaXpressOutgrowthSettings().maximum_width + """Pixel width setting enhancement, background, smoothing and topology scales.""" + + minimum_cell_growth_to_log_as_significant: float = ( + MetaXpressOutgrowthSettings().minimum_cell_growth_to_log_as_significant + ) + """Scoring-only length in pixels; does not admit or remove detected paths.""" + + +@dataclass(frozen=True) +class PixelNuclearSettings(MetaXpressNuclearSettings): + """Optional nuclear controls; inherited detection operates in source pixels.""" + + approx_min_width: float = MetaXpressNuclearSettings().approx_min_width + """Minimum nuclear-object width in pixels.""" + + approx_max_width: float = MetaXpressNuclearSettings().approx_max_width + """Maximum nuclear-object width in pixels.""" + + @dataclass(frozen=True) class NeuriteOutgrowthSummary: """MetaXpress-style image-level neurite measurements.""" @@ -390,14 +897,15 @@ class NeuriteOutgrowthSummary: cell_body_channel_index: int nuclear_channel_index: int number_of_cells: int - total_outgrowth_um: float - mean_outgrowth_per_cell_um: float + coordinate_unit: SourceVoxelSpacingUnit + total_outgrowth: float + mean_outgrowth_per_cell: float total_processes: int mean_processes_per_cell: float total_branches: int mean_branches_per_cell: float - total_cell_body_area_um2: float - mean_cell_body_area_um2: float + total_cell_body_area: float + mean_cell_body_area: float straightness: float cells_significant_growth: int percent_cells_significant_growth: float @@ -436,22 +944,23 @@ class NeuriteOutgrowthCellResult: A process is the owned path partition reached from one soma-adjacent root. Count, total, mean, median and maximum all describe those same process - lengths, including their branches. Distances use the supplied pixel size: - calibrated inputs yield micrometers; an uncalibrated unit pixel size yields - pixels despite the legacy ``_um`` column names. No independent seed-relative - skeleton measurement rescales them. + lengths, including their branches. Lengths use ``coordinate_unit`` and areas + its square. The physical declaration requires micrometer calibration; the + pixel declaration measures source pixels without asserting calibration. + No independent seed-relative skeleton measurement rescales the values. """ slice_index: int cell: int - total_outgrowth_um: float + coordinate_unit: SourceVoxelSpacingUnit + total_outgrowth: float processes: int - mean_process_length_um: float - median_process_length_um: float - max_process_length_um: float + mean_process_length: float + median_process_length: float + max_process_length: float branches: int straightness: float - cell_body_area_um2: float + cell_body_area: float mean_outgrowth_intensity: float significant_growth: bool @@ -679,8 +1188,8 @@ def count_neuronal_cell_bodies_metaxpress( illumination = NeuriteIllumination(illumination) cell_body.validate() nuclear_stain.validate("nuclear_stain") - pixel_size_um = float(pixel_size) - if not np.isfinite(pixel_size_um) or pixel_size_um <= 0: + coordinate_scale = float(pixel_size) + if not np.isfinite(coordinate_scale) or coordinate_scale <= 0: raise ValueError("pixel_size must be a finite value > 0") body_channel_index = ( @@ -701,12 +1210,12 @@ def count_neuronal_cell_bodies_metaxpress( nuclei_labels = segment_metaxpress_round_objects( image_array[nuclear_channel_index], nuclear_stain, - pixel_size_um, + coordinate_scale, ) cell_body_payload = _identify_nuclear_seeded_cell_bodies_cellprofiler( image_array[body_channel_index], cell_body, - pixel_size_um, + coordinate_scale, bright_objects=illumination == NeuriteIllumination.FLUORESCENCE, nuclei_labels=nuclei_labels, ) @@ -718,7 +1227,7 @@ def count_neuronal_cell_bodies_metaxpress( cell_results = tuple( NeuronalCellBodyResult( cell=int(region.label), - cell_body_area_um2=float(region.area) * pixel_size_um**2, + cell_body_area_um2=float(region.area) * coordinate_scale**2, mean_cell_body_intensity=float(region.mean_intensity), ) for region in regionprops(cell_body_labels, intensity_image=body_image) @@ -761,20 +1270,7 @@ def count_neuronal_cell_bodies_metaxpress( @numpy -@artifact_outputs( - NEURITE_SUMMARY_OUTPUT, - NEURITE_CELLS_OUTPUT, - CELL_BODIES_OUTPUT, - NEURITE_LABELS_OUTPUT, - UNIFIED_NEURONS_OUTPUT, - NUCLEI_OUTPUT, - NEURITE_CANDIDATE_MASK_OUTPUT, - NEURITE_UNROOTED_RESIDUAL_OUTPUT, - NEURITE_SECONDARY_OWNERSHIP_OUTPUT, - NEURITE_TOPOLOGY_DROPPED_TRACE_OUTPUT, - NEURITE_TOPOLOGY_ADDED_TRACE_OUTPUT, - NEURITE_MORPHOLOGY_OUTPUT, -) +@artifact_outputs(*CellProfilerNeuriteEngineProfile.artifact_outputs()) @artifact_inputs("pixel_size") def neurite_outgrowth_metaxpress( image, @@ -842,402 +1338,65 @@ def neurite_outgrowth_metaxpress( ROI area filtering does not remove pixels from those TIFFs. """ - image_array = np.asarray(image) - if image_array.ndim != 3: - raise ValueError( - f"Expected a 2D channel stack with shape (C, Y, X), got " - f"shape {image_array.shape}" - ) - if not 0 <= neurite_channel_index < image_array.shape[0]: - raise ValueError("neurite_channel_index is outside the input stack") - - illumination = NeuriteIllumination(illumination) - cell_body.validate() - outgrowth.validate() - pixel_size_um = float(pixel_size) - if not np.isfinite(pixel_size_um) or pixel_size_um <= 0: - raise ValueError("pixel_size must be a finite value > 0") - - body_channel_index = ( - neurite_channel_index - if cell_body.channel_index is None - else int(cell_body.channel_index) - ) - if not 0 <= body_channel_index < image_array.shape[0]: - raise ValueError("cell_body.channel_index is outside the input stack") - - nuclei_labels = np.zeros(image_array.shape[1:], dtype=np.int32) - if use_nuclear_stain: - nuclear_stain.validate("nuclear_stain") - if not 0 <= nuclear_stain.channel_index < image_array.shape[0]: - raise ValueError("nuclear_stain.channel_index is outside the input stack") - if nuclear_stain.channel_index == neurite_channel_index: - raise ValueError( - "nuclear_stain.channel_index must differ from neurite_channel_index" - ) - nuclei_labels = segment_metaxpress_round_objects( - image_array[nuclear_stain.channel_index], - nuclear_stain, - pixel_size_um, - ) - - bright_objects = illumination == NeuriteIllumination.FLUORESCENCE - nuclear_seeded_signal_body_mode = ( - use_nuclear_stain and body_channel_index == neurite_channel_index - ) - body_detection_channel_index = ( - int(nuclear_stain.channel_index) - if nuclear_seeded_signal_body_mode - else body_channel_index - ) - body_image = image_array[body_detection_channel_index] - neurite_image = image_array[neurite_channel_index] - if nuclear_seeded_signal_body_mode: - signal_cell_bodies = _derive_signal_cell_bodies( - nuclei_labels, - neurite_image, - cell_body, - pixel_size_um, - bright_objects=bright_objects, - ) - keep_signal_body = ( - np.bincount( - signal_cell_bodies.ravel(), - minlength=int(nuclei_labels.max()) + 1, - ) - > 0 - ) - keep_signal_body[0] = False - cell_body_labels = _relabel(signal_cell_bodies, keep_signal_body) - cell_body_payload = SourceImageObjectLabelBuildRequest( - image=neurite_image, - labels=cell_body_labels, - ).payload() - else: - cell_body_payload = _identify_cell_bodies_cellprofiler( - body_image, - cell_body, - pixel_size_um, - bright_objects=bright_objects, - nuclei_labels=nuclei_labels if use_nuclear_stain else None, - ) - cell_body_labels = object_label_dense_array( - cell_body_payload, - dtype=np.int32, - ) - - outgrowth_binary, outgrowth_skeleton, outgrowth_response = ( - _identify_neurites_cellprofiler( - neurite_image, - cell_body, - outgrowth, - pixel_size_um, - bright_objects=bright_objects, - ) - ) - outgrowth_width_px = outgrowth.maximum_width / pixel_size_um - nuclear_seed_mode = use_nuclear_stain and body_detection_channel_index == int( - nuclear_stain.channel_index - ) - if nuclear_seeded_signal_body_mode: - secondary_owner_regions = _propagate_neurite_owner_regions( - outgrowth_response, - cell_body_labels, - minimum_response=outgrowth.intensity_above_local_background, - ) - else: - secondary_owner_regions = _identify_secondary_owner_regions_cellprofiler( - neurite_image, - cell_body_payload, - body_width_px=cell_body.approximate_max_width / pixel_size_um, - bright_objects=bright_objects, - ) - if nuclear_seed_mode and not nuclear_seeded_signal_body_mode: - cell_body_payload = _qualify_nuclear_cell_bodies( - cell_body_payload, - cell_body_labels, - secondary_owner_regions, - ) - cell_body_labels = object_label_dense_array( - cell_body_payload, - dtype=np.int32, - ) - secondary_owner_regions = _identify_secondary_owner_regions_cellprofiler( - neurite_image, - cell_body_payload, - body_width_px=cell_body.approximate_max_width / pixel_size_um, - bright_objects=bright_objects, - ) - - topology = _analyze_topology( - outgrowth_skeleton, - cell_body_labels, - pixel_size_um, - outgrowth_width_px, - ) - owner_skeleton = _render_owned_skeleton( - outgrowth_skeleton.shape, - topology, - ) - initial_topology_owned_trace_pixels = int( - np.count_nonzero((owner_skeleton > 0) & (cell_body_labels == 0)) - ) - if nuclear_seed_mode: - owner_skeleton = _adopt_secondary_owned_path_segments( - topology, - owner_skeleton, - secondary_owner_regions, - ) - secondary_adopted_trace_pixels = int( - np.count_nonzero((owner_skeleton > 0) & (cell_body_labels == 0)) - ) - crossing_support = _render_crossing_support( - outgrowth_skeleton.shape, - topology, - ) - crossing_core_mask = _render_crossing_core_mask( - outgrowth_skeleton.shape, - topology, - ) - resolved_crossovers = _count_multi_owner_crossings( - crossing_core_mask, - crossing_support, - ) - crossing_core_support = np.where( - crossing_core_mask, - crossing_support, - 0, - ).astype(np.int32, copy=False) - owner_skeleton = _repair_signal_supported_skeleton( - owner_skeleton, - outgrowth_response, - secondary_owner_regions, - cell_body_labels, - minimum_response=outgrowth.intensity_above_local_background, - ) - owner_skeleton = np.where( - crossing_support > 0, - crossing_support, - owner_skeleton, - ).astype(np.int32, copy=False) - signal_repaired_trace_pixels = int( - np.count_nonzero((owner_skeleton > 0) & (cell_body_labels == 0)) - ) - pre_topology_owner_skeleton = owner_skeleton.copy() - pre_topology_owner_skeleton[cell_body_labels > 0] = 0 - topology = _analyze_owned_topology( - pre_topology_owner_skeleton, - cell_body_labels, - pixel_size_um, - outgrowth_width_px, - shared_crossing_mask=crossing_core_mask, - ) - neurite_skeleton = _render_owned_skeleton( - pre_topology_owner_skeleton.shape, - topology, - ) - topology_path_mask = _render_topology_path_mask( - pre_topology_owner_skeleton.shape, - topology, - ) - physically_soma_rooted_trace = _physically_soma_rooted_owner_mask( - pre_topology_owner_skeleton, - cell_body_labels, - maximum_root_distance=max(1, int(np.ceil(outgrowth_width_px)) + 2), - ) - topology_dropped_trace = ( - (pre_topology_owner_skeleton > 0) - & (neurite_skeleton == 0) - & ~crossing_core_mask - ) - topology_added_trace = (neurite_skeleton > 0) & (pre_topology_owner_skeleton == 0) - final_topology_owned_trace_pixels = int(np.count_nonzero(neurite_skeleton)) - # A physical crossing core supports two logical paths, but an object-label - # raster can store only one identity per pixel. The topology above remains - # authoritative for both neurites; publish the already-resolved nearest - # owner for each shared physical core pixel so QA and raster expansion do - # not misclassify valid crossover signal as an unrooted residual. - neurite_skeleton = np.where( - crossing_core_support > 0, - crossing_core_support, - neurite_skeleton, - ).astype(np.int32, copy=False) - owner_outgrowth = _expand_skeleton_ownership( - neurite_skeleton, - outgrowth_binary, - outgrowth_width_px, - ) - owner_outgrowth[cell_body_labels > 0] = 0 - # Secondary propagation supplies foreground evidence during detection; - # final neuron ownership comes from the same rooted topology as its traces - # and measurements. Publishing the earlier propagation would reassign - # crossing arms independently of that topology. - unified_neuron_labels = np.where( - cell_body_labels > 0, - cell_body_labels, - owner_outgrowth, - ).astype(np.int32, copy=False) - - candidate_neurite_mask = outgrowth_binary & (cell_body_labels == 0) - candidate_trace_mask = outgrowth_skeleton & (cell_body_labels == 0) - rooted_mask = owner_outgrowth > 0 - rooted_trace_mask = neurite_skeleton > 0 - unrooted_residual = candidate_neurite_mask & ~rooted_mask - secondary_owned_residual = unrooted_residual & (secondary_owner_regions > 0) - candidate_trace_pixels = int(np.count_nonzero(candidate_trace_mask)) - rooted_candidate_trace_pixels = int( - np.count_nonzero(candidate_trace_mask & rooted_trace_mask) - ) - secondary_owned_unrooted_trace_pixels = int( - np.count_nonzero( - candidate_trace_mask & ~rooted_trace_mask & (secondary_owner_regions > 0) - ) - ) - candidate_mask_pixels = int(np.count_nonzero(candidate_neurite_mask)) - rooted_candidate_mask_pixels = int( - np.count_nonzero(candidate_neurite_mask & rooted_mask) - ) - unrooted_residual_pixels = int(np.count_nonzero(unrooted_residual)) - secondary_owned_residual_pixels = int(np.count_nonzero(secondary_owned_residual)) - - cell_results = _build_cell_results( - cell_body_labels, - owner_outgrowth, - neurite_image, - topology, - outgrowth.minimum_cell_growth_to_log_as_significant, - pixel_size_um, - slice_index=body_channel_index, - ) - summary = _build_summary( - cell_results, + return CELLPROFILER_NEURITE_ENGINE_PROFILE.analyze( + image, neurite_channel_index=neurite_channel_index, - cell_body_channel_index=body_channel_index, - nuclear_channel_index=( - nuclear_stain.channel_index if use_nuclear_stain else -1 - ), - resolved_crossovers=resolved_crossovers, - mean_outgrowth_average_intensity=( - float(np.mean(neurite_image[owner_outgrowth > 0])) - if np.any(owner_outgrowth > 0) - else 0.0 - ), - candidate_trace_pixels=candidate_trace_pixels, - rooted_candidate_trace_pixels=rooted_candidate_trace_pixels, - unrooted_candidate_trace_pixels=( - candidate_trace_pixels - rooted_candidate_trace_pixels - ), - rooted_candidate_trace_yield=( - rooted_candidate_trace_pixels / candidate_trace_pixels - if candidate_trace_pixels - else 0.0 - ), - initial_topology_owned_trace_pixels=initial_topology_owned_trace_pixels, - secondary_adopted_trace_pixels=secondary_adopted_trace_pixels, - signal_repaired_trace_pixels=signal_repaired_trace_pixels, - crossing_core_trace_pixels=int(np.count_nonzero(crossing_core_mask)), - final_topology_dropped_trace_pixels=int( - np.count_nonzero(topology_dropped_trace) - ), - final_topology_dropped_crossing_support_trace_pixels=int( - np.count_nonzero(topology_dropped_trace & (crossing_support > 0)) - ), - final_topology_dropped_unrooted_path_trace_pixels=int( - np.count_nonzero(topology_dropped_trace & topology_path_mask) - ), - final_topology_dropped_physically_rooted_path_trace_pixels=int( - np.count_nonzero( - topology_dropped_trace - & topology_path_mask - & physically_soma_rooted_trace - ) - ), - final_topology_dropped_physically_unrooted_path_trace_pixels=int( - np.count_nonzero( - topology_dropped_trace - & topology_path_mask - & ~physically_soma_rooted_trace - ) - ), - final_topology_dropped_unrepresented_trace_pixels=int( - np.count_nonzero(topology_dropped_trace & ~topology_path_mask) - ), - final_topology_added_trace_pixels=int(np.count_nonzero(topology_added_trace)), - final_topology_owned_trace_pixels=final_topology_owned_trace_pixels, - published_owned_trace_pixels=int(np.count_nonzero(rooted_trace_mask)), - secondary_owned_unrooted_trace_pixels=(secondary_owned_unrooted_trace_pixels), - secondary_unowned_unrooted_trace_pixels=( - candidate_trace_pixels - - rooted_candidate_trace_pixels - - secondary_owned_unrooted_trace_pixels - ), - candidate_mask_pixels=candidate_mask_pixels, - rooted_candidate_mask_pixels=rooted_candidate_mask_pixels, - unrooted_residual_pixels=unrooted_residual_pixels, - secondary_owned_residual_pixels=secondary_owned_residual_pixels, - secondary_unowned_residual_pixels=( - unrooted_residual_pixels - secondary_owned_residual_pixels - ), - secondary_owned_residual_fraction=( - secondary_owned_residual_pixels / unrooted_residual_pixels - if unrooted_residual_pixels - else 0.0 - ), + illumination=illumination, + cell_body=cell_body, + outgrowth=outgrowth, + use_nuclear_stain=use_nuclear_stain, + nuclear_stain=nuclear_stain, + coordinate_spacing=SourceVoxelSpacing((float(pixel_size), float(pixel_size))), ) - cell_body_stack = np.zeros(image_array.shape, dtype=np.int32) - cell_body_stack[body_channel_index] = cell_body_labels - neurite_stack = np.zeros(image_array.shape, dtype=np.int32) - neurite_stack[neurite_channel_index] = neurite_skeleton - unified_neuron_stack = np.zeros(image_array.shape, dtype=np.int32) - unified_neuron_stack[neurite_channel_index] = unified_neuron_labels - nuclei_stack = np.zeros(image_array.shape, dtype=np.int32) - if use_nuclear_stain: - nuclei_stack[nuclear_stain.channel_index] = nuclei_labels - neurite_morphology = _build_neurite_morphology_graph( - topology, - cell_body_labels, - pixel_size_um=pixel_size_um, - outgrowth_width_px=outgrowth_width_px, - ) - neurite_morphology = neurite_morphology.replace_fields( - source_plane_index=neurite_channel_index - ) - return ( + +@numpy +@artifact_outputs(*PixelCellProfilerNeuriteEngineProfile.artifact_outputs()) +def neurite_outgrowth_metaxpress_pixels( + image, + neurite_channel_index: int = 0, + illumination: NeuriteIllumination = NeuriteIllumination.FLUORESCENCE, + cell_body: PixelCellBodySettings = PixelCellBodySettings(), + outgrowth: PixelOutgrowthSettings = PixelOutgrowthSettings(), + use_nuclear_stain: bool = False, + nuclear_stain: PixelNuclearSettings = PixelNuclearSettings(), +) -> tuple[ + np.ndarray, + DataclassMeasurementColumnarRows, + DataclassMeasurementColumnarRows, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + SelectedPlaneImageOutput, + SelectedPlaneImageOutput, + SelectedPlaneImageOutput, + SelectedPlaneImageOutput, + SelectedPlaneImageOutput, + SpatialGraph, +]: + """Measure soma-rooted neurite morphology explicitly in source-pixel units. + + Input is a 2D channel stack (C, Y, X). Widths and significant-growth lengths + are pixels; minimum body area is square pixels. Intensity cutoffs retain + consumed-image units. Detection and rooted ownership use the same recipe + as the physical callable, without requiring micrometer calibration. + + Rows and graph edges declare their coordinate unit. The graph's analysis + metric does not replace acquisition calibration: original source metadata + contextualizes native ROI placement. Pixel graphs publish graph ROIs, not + physical SWC files. The unchanged image and twelve declared artifacts have + the same identity/subject relations as the physical recipe. + """ + return PIXEL_CELLPROFILER_NEURITE_ENGINE_PROFILE.analyze( image, - DataclassMeasurementColumnarRows( - (summary,), - row_type=NeuriteOutgrowthSummary, - ), - DataclassMeasurementColumnarRows( - tuple(cell_results), - row_type=NeuriteOutgrowthCellResult, - ), - cell_body_stack, - neurite_stack, - unified_neuron_stack, - nuclei_stack, - SelectedPlaneImageOutput( - outgrowth_binary.astype(np.uint8, copy=False)[None], - (neurite_channel_index,), - ), - SelectedPlaneImageOutput( - unrooted_residual.astype(np.uint8, copy=False)[None], - (neurite_channel_index,), - ), - SelectedPlaneImageOutput( - secondary_owner_regions.astype(np.int32, copy=False)[None], - (neurite_channel_index,), - ), - SelectedPlaneImageOutput( - topology_dropped_trace.astype(np.uint8, copy=False)[None], - (neurite_channel_index,), - ), - SelectedPlaneImageOutput( - topology_added_trace.astype(np.uint8, copy=False)[None], - (neurite_channel_index,), - ), - neurite_morphology, + neurite_channel_index=neurite_channel_index, + illumination=illumination, + cell_body=cell_body, + outgrowth=outgrowth, + use_nuclear_stain=use_nuclear_stain, + nuclear_stain=nuclear_stain, + coordinate_spacing=PIXEL_CELLPROFILER_NEURITE_ENGINE_PROFILE.analysis_spacing(), ) @@ -1269,14 +1428,14 @@ def _cellprofiler_adaptive_window( def _identify_cell_bodies_cellprofiler( image: np.ndarray, settings: MetaXpressCellBodySettings, - pixel_size_um: float, + coordinate_scale: float, *, bright_objects: bool, nuclei_labels: np.ndarray | None = None, ): """Detect with CP IPO, then apply the MetaXpress-owned body predicates.""" - maximum_width_px = settings.approximate_max_width / pixel_size_um + maximum_width_px = settings.maximum_width_px(coordinate_scale) _, _, detected_payload, *_ = _raw_processing_leaf(identify_primary_objects)( _cellprofiler_foreground_image(image, bright_objects=bright_objects), **CELLPROFILER_NEURITE_ENGINE_PROFILE.compact_body_detection_kwargs( @@ -1295,7 +1454,7 @@ def _identify_cell_bodies_cellprofiler( contract_candidates = settings.contract_candidates( detected_labels, response, - pixel_size_um, + coordinate_scale, ) candidate_labels = np.where( @@ -1325,18 +1484,18 @@ def _identify_cell_bodies_cellprofiler( def _identify_nuclear_seeded_cell_bodies_cellprofiler( image: np.ndarray, settings: MetaXpressCellBodySettings, - pixel_size_um: float, + coordinate_scale: float, *, bright_objects: bool, nuclei_labels: np.ndarray, ): """Propagate DAPI seeds through soma signal, then apply the body contract.""" - maximum_width_px = settings.approximate_max_width / pixel_size_um + maximum_width_px = settings.maximum_width_px(coordinate_scale) seed_payload_template = _identify_cell_bodies_cellprofiler( image, settings, - pixel_size_um, + coordinate_scale, bright_objects=bright_objects, ) nuclear_seed_payload = object_label_value_with_dense_labels( @@ -1366,7 +1525,7 @@ def _identify_nuclear_seeded_cell_bodies_cellprofiler( keep = settings.contract_candidates( propagated_labels, response, - pixel_size_um, + coordinate_scale, ) return object_label_value_with_dense_labels( propagated_payload, @@ -1463,14 +1622,14 @@ def _identify_neurites_cellprofiler( image: np.ndarray, cell_body: MetaXpressCellBodySettings, settings: MetaXpressOutgrowthSettings, - pixel_size_um: float, + coordinate_scale: float, *, bright_objects: bool, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Return the public mask, CP medial axis, and local signal evidence.""" - outgrowth_width_px = settings.maximum_width / pixel_size_um - body_width_px = cell_body.approximate_max_width / pixel_size_um + outgrowth_width_px = settings.maximum_width_px(coordinate_scale) + body_width_px = cell_body.maximum_width_px(coordinate_scale) cp_image = _cellprofiler_foreground_image( image, bright_objects=bright_objects, @@ -1658,7 +1817,7 @@ def _derive_signal_cell_bodies( nuclear_seed_labels: np.ndarray, neurite_image: np.ndarray, settings: MetaXpressCellBodySettings, - pixel_size_um: float, + coordinate_scale: float, *, bright_objects: bool, ) -> np.ndarray: @@ -1667,11 +1826,12 @@ def _derive_signal_cell_bodies( seeds = np.asarray(nuclear_seed_labels, dtype=np.int32) if seeds.shape != neurite_image.shape: raise ValueError("nuclear seeds and neurite image must share a shape") - maximum_radius_px = settings.approximate_max_width / (2.0 * pixel_size_um) - minimum_area_px = settings.minimum_area / pixel_size_um**2 + maximum_width_px = settings.maximum_width_px(coordinate_scale) + maximum_radius_px = maximum_width_px / 2.0 + minimum_area_px = settings.minimum_area_px(coordinate_scale) response = local_background_response( neurite_image, - object_width_px=settings.approximate_max_width / pixel_size_um, + object_width_px=maximum_width_px, bright_objects=bright_objects, ) body_foreground = response >= settings.intensity_above_local_background @@ -1703,7 +1863,7 @@ def _derive_signal_cell_bodies( local_foreground_width = ( 2.0 * float(foreground_distance[owner_slice][seed_centroid]) - 1.0 ) - if local_foreground_width > settings.approximate_max_width / pixel_size_um: + if local_foreground_width > maximum_width_px: continue distance_to_nearest_seed, nearest_seed_coordinates = ndi.distance_transform_edt( local_seeds == 0, @@ -1956,7 +2116,7 @@ def _remove_three_pixel_cycles( def _analyze_topology( skeleton: np.ndarray, cell_body_labels: np.ndarray, - pixel_size_um: float, + coordinate_scale: float, outgrowth_width_px: float, *, assigned_path_labels: np.ndarray | None = None, @@ -1983,7 +2143,7 @@ def _analyze_topology( if not skeleton.any(): return _empty_topology() - skeleton_graph = Skeleton(skeleton, spacing=pixel_size_um) + skeleton_graph = Skeleton(skeleton, spacing=coordinate_scale) branch_table = summarize(skeleton_graph, separator="_").reset_index(drop=True) path_count = skeleton_graph.n_paths if path_count == 0: @@ -2044,7 +2204,7 @@ def _analyze_topology( path_endpoint_nodes, path_lengths, maximum_internal_length=( - max(1.0, np.sqrt(2.0) * outgrowth_width_px) * pixel_size_um + max(1.0, np.sqrt(2.0) * outgrowth_width_px) * coordinate_scale ), ): external_endpoints = tuple( @@ -2271,7 +2431,7 @@ def _analyze_topology( def _analyze_owned_topology( owner_skeleton: np.ndarray, cell_body_labels: np.ndarray, - pixel_size_um: float, + coordinate_scale: float, outgrowth_width_px: float, *, shared_crossing_mask: np.ndarray | None = None, @@ -2371,7 +2531,7 @@ def _analyze_owned_topology( local = _analyze_topology( local_owned > 0, bodies[owner_slice], - pixel_size_um, + coordinate_scale, outgrowth_width_px, assigned_path_labels=local_owned, ) @@ -2692,11 +2852,12 @@ def _build_neurite_morphology_graph( topology: _TopologyResult, cell_body_labels: np.ndarray, *, - pixel_size_um: float, + coordinate_spacing: SourceVoxelSpacing, outgrowth_width_px: float, ) -> SpatialGraph: """Project owned Skan paths into deterministic soma-rooted forests.""" + coordinate_scale = coordinate_spacing.isotropic_xy_spacing paths_by_owner: dict[int, list[int]] = defaultdict(list) for path_index, owner in enumerate(topology.path_owners): if path_index in topology.crossing_core_paths: @@ -2708,9 +2869,9 @@ def _build_neurite_morphology_graph( graph_edges: list[SpatialGraphEdge] = [] next_node_id = 1 next_edge_id = 1 - process_radius_um = max( - pixel_size_um / 2.0, - outgrowth_width_px * pixel_size_um / 2.0, + process_radius = max( + coordinate_scale / 2.0, + outgrowth_width_px * coordinate_scale / 2.0, ) body_regions = { int(region.label): region for region in regionprops(cell_body_labels) @@ -2740,11 +2901,11 @@ def _build_neurite_morphology_graph( np.argmin(np.sum((body_coordinates - body_centroid) ** 2, axis=1)) ] ) - body_area_um2 = float(body_region.area) * pixel_size_um**2 + body_area = float(body_region.area) * coordinate_scale**2 soma_tree = cKDTree(body_coordinates) - soma_radius_um = max( - process_radius_um, - float(np.sqrt(body_area_um2 / np.pi)), + soma_radius = max( + process_radius, + float(np.sqrt(body_area / np.pi)), ) primary_root_group = min( adjacency, @@ -2824,7 +2985,7 @@ def _build_neurite_morphology_graph( node_id=next_node_id, coordinates=root_coordinate, radius=( - soma_radius_um if root_role == "soma_root" else process_radius_um + soma_radius if root_role == "soma_root" else process_radius ), features={ "label": owner, @@ -2841,7 +3002,7 @@ def _build_neurite_morphology_graph( node = SpatialGraphNode.from_features( node_id=next_node_id, coordinates=topology.endpoint_group_coordinates[group_id], - radius=process_radius_um, + radius=process_radius, features={ "label": owner, "neuron_label": owner, @@ -2898,7 +3059,7 @@ def enqueue_from(group_id: int) -> None: target_node = SpatialGraphNode.from_features( node_id=next_node_id, coordinates=coordinates[-1], - radius=process_radius_um, + radius=process_radius, features={ "label": owner, "neuron_label": owner, @@ -2912,8 +3073,8 @@ def enqueue_from(group_id: int) -> None: node_distances[target_group] = target_distance target_node = nodes_by_group[target_group] coordinates[-1] = target_node.coordinates - branch_distance_um = float(topology.path_lengths[path_index]) - euclidean_distance_um = float( + branch_distance = float(topology.path_lengths[path_index]) + euclidean_distance = float( topology.path_euclidean_lengths[path_index] ) graph_edges.append( @@ -2925,17 +3086,18 @@ def enqueue_from(group_id: int) -> None: features={ "label": owner, "neuron_label": owner, - "branch_distance_um": branch_distance_um, - "euclidean_distance_um": euclidean_distance_um, + "coordinate_unit": coordinate_spacing.unit.value, + "branch_distance": branch_distance, + "euclidean_distance": euclidean_distance, "tortuosity": ( max( 1.0, - branch_distance_um / euclidean_distance_um, + branch_distance / euclidean_distance, ) - if euclidean_distance_um > 0 + if euclidean_distance > 0 else 0.0 ), - "distance_from_soma_um": ( + "distance_from_soma": ( node_distances[source_group] if soma_connected else float("nan") @@ -2953,7 +3115,7 @@ def enqueue_from(group_id: int) -> None: name=NEURITE_MORPHOLOGY_OUTPUT.name, nodes=tuple(graph_nodes), edges=tuple(graph_edges), - coordinate_spacing=(pixel_size_um, pixel_size_um), + coordinate_spacing=coordinate_spacing, ) graph.require_directed_forest() return graph @@ -3151,16 +3313,17 @@ def _build_cell_results( owner_outgrowth: np.ndarray, neurite_image: np.ndarray, topology: _TopologyResult, - significant_growth_threshold_um: float, - pixel_size_um: float, + significant_growth_threshold: float, + coordinate_spacing: SourceVoxelSpacing, *, slice_index: int, ) -> list[NeuriteOutgrowthCellResult]: + coordinate_scale = coordinate_spacing.isotropic_xy_spacing cell_count = int(cell_body_labels.max()) body_areas = np.bincount(cell_body_labels.ravel(), minlength=cell_count + 1).astype( float ) - body_areas *= pixel_size_um**2 + body_areas *= coordinate_scale**2 outgrowth_labels = owner_outgrowth.ravel() outgrowth_pixel_counts = np.bincount( outgrowth_labels, @@ -3191,22 +3354,23 @@ def _build_cell_results( NeuriteOutgrowthCellResult( slice_index=slice_index, cell=cell, - total_outgrowth_um=total_outgrowth, + coordinate_unit=coordinate_spacing.unit, + total_outgrowth=total_outgrowth, processes=process_count, - mean_process_length_um=( + mean_process_length=( total_outgrowth / process_count if process_count else 0.0 ), - median_process_length_um=( + median_process_length=( float(np.median(process_lengths)) if process_lengths else 0.0 ), - max_process_length_um=( + max_process_length=( float(np.max(process_lengths)) if process_lengths else 0.0 ), branches=len(topology.branch_nodes_by_cell.get(cell, ())), straightness=straightness, - cell_body_area_um2=float(body_areas[cell]), + cell_body_area=float(body_areas[cell]), mean_outgrowth_intensity=mean_intensity, - significant_growth=(total_outgrowth > significant_growth_threshold_um), + significant_growth=(total_outgrowth > significant_growth_threshold), ) ) return results @@ -3254,6 +3418,7 @@ def _measure_process_lengths( def _build_summary( cell_results: Sequence[NeuriteOutgrowthCellResult], *, + coordinate_unit: SourceVoxelSpacingUnit, neurite_channel_index: int, cell_body_channel_index: int, nuclear_channel_index: int, @@ -3286,26 +3451,27 @@ def _build_summary( secondary_owned_residual_fraction: float, ) -> NeuriteOutgrowthSummary: cell_count = len(cell_results) - total_outgrowth = float(sum(row.total_outgrowth_um for row in cell_results)) + total_outgrowth = float(sum(row.total_outgrowth for row in cell_results)) total_processes = int(sum(row.processes for row in cell_results)) total_branches = int(sum(row.branches for row in cell_results)) - total_body_area = float(sum(row.cell_body_area_um2 for row in cell_results)) + total_body_area = float(sum(row.cell_body_area for row in cell_results)) significant_count = sum(row.significant_growth for row in cell_results) return NeuriteOutgrowthSummary( neurite_channel_index=neurite_channel_index, cell_body_channel_index=cell_body_channel_index, nuclear_channel_index=nuclear_channel_index, number_of_cells=cell_count, - total_outgrowth_um=total_outgrowth, - mean_outgrowth_per_cell_um=( + coordinate_unit=coordinate_unit, + total_outgrowth=total_outgrowth, + mean_outgrowth_per_cell=( total_outgrowth / cell_count if cell_count else 0.0 ), total_processes=total_processes, mean_processes_per_cell=(total_processes / cell_count if cell_count else 0.0), total_branches=total_branches, mean_branches_per_cell=(total_branches / cell_count if cell_count else 0.0), - total_cell_body_area_um2=total_body_area, - mean_cell_body_area_um2=(total_body_area / cell_count if cell_count else 0.0), + total_cell_body_area=total_body_area, + mean_cell_body_area=(total_body_area / cell_count if cell_count else 0.0), straightness=( float(np.mean([row.straightness for row in cell_results])) if cell_results diff --git a/openhcs/processing/materialization/core.py b/openhcs/processing/materialization/core.py index 035c0cc4e9..e57c17fe99 100644 --- a/openhcs/processing/materialization/core.py +++ b/openhcs/processing/materialization/core.py @@ -3322,6 +3322,8 @@ def _write_spatial_graph_swc( ) graph.require_directed_forest() + physical_spacing = graph.coordinate_spacing.require_physical_coordinates() + outgoing = {node.node_id: [] for node in graph.nodes} for edge in graph.edges: outgoing[edge.source.node_id].append(edge) @@ -3341,7 +3343,7 @@ def append_sample( nonlocal next_sample_id sample_id = next_sample_id next_sample_id += 1 - x, y, z = _swc_xyz(coordinates, graph.coordinate_spacing) + x, y, z = _swc_xyz(coordinates, physical_spacing) rows.append( f"{sample_id} {sample_type} {x:.9g} {y:.9g} {z:.9g} " f"{radius:.9g} {parent_sample_id}" @@ -3510,7 +3512,7 @@ def _write_spatial_graph_roi_zip( source_metadata = ImagePayloadMetadata( source_provenance=graph.source_provenance, - source_voxel_spacing=SourceVoxelSpacing(graph.coordinate_spacing), + source_voxel_spacing=graph.source_voxel_spacing, ) rois = [] for edge in graph.edges: diff --git a/tests/integration/test_neurite_outgrowth_synthetic_plate.py b/tests/integration/test_neurite_outgrowth_synthetic_plate.py index 143033fdd8..9124584bd6 100644 --- a/tests/integration/test_neurite_outgrowth_synthetic_plate.py +++ b/tests/integration/test_neurite_outgrowth_synthetic_plate.py @@ -465,10 +465,10 @@ def observe_dense_output(output, components): assert isinstance(branch_roi.shapes[0], PolylineShape) assert branch_roi.metadata["label"] == 1 assert branch_roi.metadata["neuron_label"] == 1 - assert branch_roi.metadata["branch_distance_um"] > 0 - assert branch_roi.metadata["euclidean_distance_um"] > 0 + assert branch_roi.metadata["branch_distance"] > 0 + assert branch_roi.metadata["euclidean_distance"] > 0 assert branch_roi.metadata["tortuosity"] >= 1.0 - assert branch_roi.metadata["distance_from_soma_um"] >= 0 + assert branch_roi.metadata["distance_from_soma"] >= 0 assert "branch_type" in branch_roi.metadata summaries = sorted( diff --git a/tests/unit/test_cellprofiler_full_stack_materialization.py b/tests/unit/test_cellprofiler_full_stack_materialization.py index fff304a769..02a7d4be66 100644 --- a/tests/unit/test_cellprofiler_full_stack_materialization.py +++ b/tests/unit/test_cellprofiler_full_stack_materialization.py @@ -366,6 +366,7 @@ def test_full_stack_preserves_nonimage_identity_domains_and_dense_images(): name="graph", nodes=(SpatialGraphNode(1, (2.0, 3.0)),), edges=(), + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), ) array = np.ones((2, 3), dtype=np.float32) image = ImagePayloadMetadata(source_path="/synthetic/source.tif").payload_with( diff --git a/tests/unit/test_compilation_session.py b/tests/unit/test_compilation_session.py index 0a6d51b6a8..6bc1f4b71e 100644 --- a/tests/unit/test_compilation_session.py +++ b/tests/unit/test_compilation_session.py @@ -55,6 +55,7 @@ from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.analysis.neurite_outgrowth import ( neurite_outgrowth_metaxpress, + neurite_outgrowth_metaxpress_pixels, ) @@ -756,7 +757,16 @@ def test_path_planner_step_output_projects_only_exact_source_artifacts() -> None assert source_binding_plan.bindings == (dna,) -def test_path_planner_preserves_metaxpress_primary_source_order() -> None: +@pytest.mark.parametrize( + "function,artifact_input_names", + ( + (neurite_outgrowth_metaxpress, ("pixel_size",)), + (neurite_outgrowth_metaxpress_pixels, ()), + ), +) +def test_path_planner_preserves_metaxpress_primary_source_order( + function, artifact_input_names +) -> None: pipeline_bindings = ( NamedSourceBinding(alias="Hoechst"), NamedSourceBinding(alias="MAP2"), @@ -766,7 +776,7 @@ def test_path_planner_preserves_metaxpress_primary_source_order() -> None: NamedSourceBinding(alias="SMI312"), NamedSourceBinding(alias="Hoechst"), ) - step = FunctionStep(func=neurite_outgrowth_metaxpress, name="neurite") + step = FunctionStep(func=function, name="neurite") snapshot = _resolved_step( step, variable_components=(VariableComponents.CHANNEL,), @@ -789,18 +799,18 @@ def test_path_planner_preserves_metaxpress_primary_source_order() -> None: step_provenance={0: {}}, ), ) - contract = CallableContract.from_callable(neurite_outgrowth_metaxpress) + contract = CallableContract.from_callable(function) execution_bindings, (source_binding_plan, _source_universe_plan) = ( _compile_source_plans_for_contract( session, snapshot, - neurite_outgrowth_metaxpress, + function, ) ) assert contract.accepts_implicit_main_flow_input is True - assert contract.artifact_inputs.names() == ("pixel_size",) + assert contract.artifact_inputs.names() == artifact_input_names assert tuple(binding.alias for binding in pipeline_bindings) == ( "Hoechst", "MAP2", diff --git a/tests/unit/test_count_cells_simple.py b/tests/unit/test_count_cells_simple.py index 088c28fa23..86651cc3bb 100644 --- a/tests/unit/test_count_cells_simple.py +++ b/tests/unit/test_count_cells_simple.py @@ -1,5 +1,5 @@ import importlib -from dataclasses import fields, replace +from dataclasses import dataclass, fields, replace from inspect import signature, unwrap import numpy as np @@ -32,6 +32,49 @@ ) +def test_independent_wavelength_projection_uses_cooperative_hooks_in_round_detector(): + projections = [] + + class ProjectionAudit: + def minimum_width_px(self, pixel_size_um): + projections.append("minimum") + return super().minimum_width_px(pixel_size_um) + + def maximum_width_px(self, pixel_size_um): + projections.append("maximum") + return super().maximum_width_px(pixel_size_um) + + class HalfWidthProjection: + def minimum_width_px(self, pixel_size_um): + return super().minimum_width_px(pixel_size_um) / 2.0 + + def maximum_width_px(self, pixel_size_um): + return super().maximum_width_px(pixel_size_um) / 2.0 + + @dataclass(frozen=True) + class AuditedWavelength( + ProjectionAudit, HalfWidthProjection, MetaXpressWavelengthSettings + ): + pass + + image = np.zeros((48, 48), dtype=np.uint16) + rows, columns = disk((24, 24), 6, shape=image.shape) + image[rows, columns] = 1000 + physical = MetaXpressWavelengthSettings( + approx_min_width=5.0, approx_max_width=20.0, + intensity_above_local_background=100.0, + ) + declared = AuditedWavelength( + approx_min_width=10.0, approx_max_width=40.0, + intensity_above_local_background=100.0, + ) + actual = segment_metaxpress_round_objects(image, declared, 1.3556) + expected = segment_metaxpress_round_objects(image, physical, 1.3556) + assert np.any(actual) + np.testing.assert_array_equal(actual, expected) + assert projections == ["minimum", "maximum"] + + def _count_cells_simple_impl(): return unwrap(count_cells_simple) diff --git a/tests/unit/test_graph_roi_source_roundtrip_134.py b/tests/unit/test_graph_roi_source_roundtrip_134.py index 7de36f894f..ea9deeeb1b 100644 --- a/tests/unit/test_graph_roi_source_roundtrip_134.py +++ b/tests/unit/test_graph_roi_source_roundtrip_134.py @@ -57,9 +57,9 @@ def __init__(self, **kwargs): self.projection_events = [] super().__init__(**kwargs) - def contextualized_source_provenance(self, provenance): + def contextualized_source_metadata(self, metadata): self.projection_events.append("before") - result = super().contextualized_source_provenance(provenance) + result = super().contextualized_source_metadata(metadata) self.projection_events.append("after") return result @@ -93,7 +93,7 @@ def graph_and_plan(*, plane, graph_type=SpatialGraph, extra_features=None): name="declared_graph", nodes=nodes, edges=edges, - coordinate_spacing=(1.3556, 1.3556), + coordinate_spacing=SourceVoxelSpacing((1.3556, 1.3556)), source_plane_index=plane, ) neurons = ArtifactSpec.output("neurons", ObjectLabelsArtifactType) @@ -129,7 +129,7 @@ def contextualize(graph, plan): ), ), source_image_names=("nuclear", "body"), - source_voxel_spacing=SourceVoxelSpacing(graph.coordinate_spacing), + source_voxel_spacing=graph.coordinate_spacing, ) source = metadata.payload_with(np.zeros((2, 16, 16), dtype=np.uint8)) result = (ImageArtifactType if plan is None else plan.artifact_type).contextualize_output( @@ -178,7 +178,7 @@ def test_declared_source_plane_survives_graph_writer_zip_and_subject_features( == ("/engineering/nuclear.tif", "/engineering/body.tif")[plane] ) assert metadata.source_component_metadata["channel"] == plane + 1 - assert metadata.source_voxel_spacing == SourceVoxelSpacing(graph.coordinate_spacing) + assert metadata.source_voxel_spacing == graph.source_voxel_spacing assert metadata.plane_axis is None assert metadata.source_spatial_domain == output.metadata.source_spatial_domain assert path.name == "misleading_w99_z999.graph.roi.zip" diff --git a/tests/unit/test_neurite_cell_body_size_control.py b/tests/unit/test_neurite_cell_body_size_control.py index 3e454eae5e..1219106588 100644 --- a/tests/unit/test_neurite_cell_body_size_control.py +++ b/tests/unit/test_neurite_cell_body_size_control.py @@ -9,6 +9,7 @@ from openhcs.processing.backends.analysis.neurite_outgrowth import ( CELLPROFILER_NEURITE_ENGINE_PROFILE, MetaXpressCellBodySettings, + _derive_signal_cell_bodies, _identify_cell_bodies_cellprofiler, _identify_nuclear_seeded_cell_bodies_cellprofiler, ) @@ -123,3 +124,74 @@ def test_body_size_gate_requires_finite_nonnegative_pixel_units(diameter): settings = MetaXpressCellBodySettings(minimum_inscribed_diameter_px=diameter) with pytest.raises(ValueError, match="minimum_inscribed_diameter_px"): settings.validate() + + +@pytest.mark.parametrize("detector", ("primary", "nuclear_seeded", "signal_body")) +def test_independent_body_projection_composes_through_existing_detectors(detector): + """A declaration changes controls, not the shared detection consumers. + + This local candidate declaration is not the pending registered pixel-unit + neurite route: graph/measurement units remain a separate required contract. + """ + projections = [] + + class ProjectionAudit: + def maximum_width_px(self, pixel_size_um): + projections.append("width") + return super().maximum_width_px(pixel_size_um) + + def minimum_area_px(self, pixel_size_um): + projections.append("area") + return super().minimum_area_px(pixel_size_um) + + @dataclass(frozen=True) + class PixelCandidateDeclaration(MetaXpressCellBodySettings): + body_width_pixels: float = 30.0 + body_area_pixels: float = 20.0 + + def maximum_width_px(self, pixel_size_um): + return self.body_width_pixels + + def minimum_area_px(self, pixel_size_um): + return self.body_area_pixels + + @dataclass(frozen=True) + class AuditedPixelCandidate(ProjectionAudit, PixelCandidateDeclaration): + pass + + calibration = 1.3556 + declared = AuditedPixelCandidate( + approximate_max_width=1.0, + minimum_area=10000.0, + minimum_inscribed_diameter_px=5.0, + ) + physical = MetaXpressCellBodySettings( + approximate_max_width=declared.body_width_pixels * calibration, + minimum_area=declared.body_area_pixels * calibration**2, + minimum_inscribed_diameter_px=5.0, + ) + image = _small_body_image() + nuclei = np.zeros(image.shape, dtype=np.int32) + nuclei[30:33, 30:33] = 1 + + def detect(settings): + if detector == "signal_body": + return _derive_signal_cell_bodies( + nuclei, image, settings, calibration, bright_objects=True + ) + if detector == "nuclear_seeded": + payload = _identify_nuclear_seeded_cell_bodies_cellprofiler( + image, settings, calibration, bright_objects=True, + nuclei_labels=nuclei, + ) + else: + payload = _identify_cell_bodies_cellprofiler( + image, settings, calibration, bright_objects=True + ) + return object_label_dense_array(payload) + + actual = detect(declared) + expected = detect(physical) + assert np.any(actual) + np.testing.assert_array_equal(actual, expected) + assert "width" in projections and "area" in projections diff --git a/tests/unit/test_neurite_outgrowth.py b/tests/unit/test_neurite_outgrowth.py index 917eec8e6c..bd4ce6743f 100644 --- a/tests/unit/test_neurite_outgrowth.py +++ b/tests/unit/test_neurite_outgrowth.py @@ -1,4 +1,4 @@ -from dataclasses import fields +from dataclasses import dataclass, fields from inspect import signature import numpy as np @@ -24,12 +24,17 @@ object_label_dense_array, ) from openhcs.core.runtime_spatial_graph import SpatialGraph +from openhcs.core.source_metadata import SourceVoxelSpacing, SourceVoxelSpacingUnit from openhcs.processing.backends.analysis.neurite_outgrowth import ( CELLPROFILER_NEURITE_ENGINE_PROFILE, NEURITE_OBJECT_LABEL_MATERIALIZATION, MetaXpressCellBodySettings, MetaXpressNuclearSettings, MetaXpressOutgrowthSettings, + PixelCellBodySettings, + PixelNuclearSettings, + PixelOutgrowthSettings, + PixelCellProfilerNeuriteEngineProfile, NeuriteIllumination, _adopt_secondary_owned_path_segments, _analyze_owned_topology, @@ -47,8 +52,10 @@ _TopologyResult, count_neuronal_cell_bodies_metaxpress, neurite_outgrowth_metaxpress, + neurite_outgrowth_metaxpress_pixels, ) from openhcs.processing.materialization import ( + MaterializationSpec, ImageFileOptions, ROIOptions, SpatialGraphROIOptions, @@ -72,6 +79,215 @@ def _cell_body_count_implementation(): ).resolve_raw_runtime_callable() +@pytest.mark.parametrize("branched", [False, True]) +@pytest.mark.parametrize("with_nuclei", [False, True]) +def test_pixel_recipe_matches_physical_detection_and_declares_metric_units( + branched, with_nuclei +): + image = _draw_fluorescent_neuron(branched=branched) + if with_nuclei: + body_and_process = _with_separate_body_channel(image) + nuclei = np.zeros_like(image[0]) + rows, columns = disk((64, 20), 4, shape=nuclei.shape) + nuclei[rows, columns] = 1000 + image = np.concatenate((body_and_process, nuclei[None]), axis=0) + before = image.copy() + pixel_body = PixelCellBodySettings( + approximate_max_width=30, minimum_area=100, + intensity_above_local_background=100, + channel_index=0 if with_nuclei else None, + ) + pixel_growth = PixelOutgrowthSettings( + maximum_width=3, intensity_above_local_background=100, + minimum_cell_growth_to_log_as_significant=20, + ) + contract = CallableContract.from_callable(neurite_outgrowth_metaxpress_pixels) + assert contract.artifact_inputs.names() == () + assert "pixel_size" not in signature(contract.resolve_raw_runtime_callable()).parameters + pixel = contract.resolve_raw_runtime_callable()( + image, cell_body=pixel_body, outgrowth=pixel_growth, + neurite_channel_index=1 if with_nuclei else 0, + use_nuclear_stain=with_nuclei, + nuclear_stain=PixelNuclearSettings(channel_index=2), + ) + physical = _implementation()( + image, + cell_body=MetaXpressCellBodySettings( + approximate_max_width=15, minimum_area=25, + intensity_above_local_background=100, + channel_index=0 if with_nuclei else None, + ), + outgrowth=MetaXpressOutgrowthSettings( + maximum_width=1.5, intensity_above_local_background=100, + minimum_cell_growth_to_log_as_significant=10, + ), + pixel_size=0.5, + neurite_channel_index=1 if with_nuclei else 0, + use_nuclear_stain=with_nuclei, + nuclear_stain=MetaXpressNuclearSettings( + channel_index=2, approx_min_width=2.5, approx_max_width=15 + ), + ) + assert pixel[0] is physical[0] is image + np.testing.assert_array_equal(image, before) + for actual, expected in zip(pixel[3:7], physical[3:7], strict=True): + np.testing.assert_array_equal(actual, expected) + assert _rows(pixel[1])[0]["number_of_cells"] > 0 + assert pixel[-1].edges + assert pixel[-1].coordinate_spacing == SourceVoxelSpacing( + (1, 1), unit=SourceVoxelSpacingUnit.PIXELS + ) + for px, um in zip(_rows(pixel[2]), _rows(physical[2]), strict=True): + assert px["coordinate_unit"] is SourceVoxelSpacingUnit.PIXELS + assert um["coordinate_unit"] is SourceVoxelSpacingUnit.MICROMETERS + assert px["cell"] == um["cell"] + assert px["total_outgrowth"] == pytest.approx(2 * um["total_outgrowth"]) + assert px["cell_body_area"] == pytest.approx(4 * um["cell_body_area"]) + assert px["significant_growth"] == um["significant_growth"] + for px, um in zip(pixel[-1].edges, physical[-1].edges, strict=True): + np.testing.assert_array_equal(px.coordinates, um.coordinates) + assert px.feature_mapping()["coordinate_unit"] == "pixels" + assert px.feature_mapping()["branch_distance"] == pytest.approx( + 2 * um.feature_mapping()["branch_distance"] + ) + morphology = contract.artifact_outputs[-1] + assert tuple(type(option) for option in morphology.materialization.outputs) == ( + SpatialGraphROIOptions, + ) + assert morphology.relations == CallableContract.from_callable( + neurite_outgrowth_metaxpress + ).artifact_outputs[-1].relations + + +@pytest.mark.parametrize( + "spacing", + ( + SourceVoxelSpacing(), + SourceVoxelSpacing((1, 1), unit=SourceVoxelSpacingUnit.RELATIVE), + SourceVoxelSpacing((1.3556, 1.3556)), + ), +) +def test_pixel_recipe_rows_csv_and_native_graph_roi_keep_distinct_source_units( + tmp_path, spacing +): + import csv + from pathlib import Path + from polystore.roi import load_rois_from_zip + from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata + from openhcs.core.runtime_image_values import ImagePayloadMetadata + from openhcs.core.source_image_provenance import SourceImageProvenancePlanes + + image = _draw_fluorescent_neuron(branched=True) + result = CallableContract.from_callable( + neurite_outgrowth_metaxpress_pixels + ).resolve_raw_runtime_callable()( + image, + cell_body=PixelCellBodySettings( + approximate_max_width=30, minimum_area=100, + intensity_above_local_background=100, + ), + outgrowth=PixelOutgrowthSettings( + maximum_width=3, intensity_above_local_background=100, + minimum_cell_growth_to_log_as_significant=20, + ), + ) + metadata = ImagePayloadMetadata( + source_voxel_spacing=spacing, + source_image_provenance_planes=SourceImageProvenancePlanes.from_components( + paths=("/engineering/A01_s1_w2.tif",), + component_metadata=({ + "well": "A01", "site": 1, "channel": 2, + "z_index": 1, "timepoint": 1, + },), + ), + ) + source = metadata.payload_with(image) + graph = SpatialGraphArtifactType.contextualize_output( + source, result[-1], None, None + ) + assert graph.source_voxel_spacing == spacing + assert graph.coordinate_spacing.unit is SourceVoxelSpacingUnit.PIXELS + manager = FileManager({"disk": DiskStorageBackend()}) + declarations = CallableContract.from_callable( + neurite_outgrowth_metaxpress_pixels + ).artifact_outputs + csv_path = materialize( + declarations[1].materialization, data=result[2], + path=str(tmp_path / "cells.csv"), filemanager=manager, + backends=["disk"], backend_kwargs={}, + ) + with open(csv_path, newline="") as stream: + rows = tuple(csv.DictReader(stream)) + assert rows + assert {row["coordinate_unit"] for row in rows} == {"pixels"} + assert float(rows[0]["total_outgrowth"]) > 0 + zip_path = materialize( + declarations[-1].materialization, data=graph, + path=str(tmp_path / "morphology.roi.zip"), filemanager=manager, + backends=["disk"], backend_kwargs={}, + ) + restored = load_rois_from_zip(Path(zip_path)) + saved = ROIArchiveSourceMetadata.decode(restored) + assert saved.source_voxel_spacing == spacing + assert saved.source_path == "/engineering/A01_s1_w2.tif" + geometry = ROIArchiveSourceMetadata.geometry(restored) + assert len(geometry) == len(graph.edges) + for roi, edge in zip(geometry, graph.edges, strict=True): + np.testing.assert_array_equal(roi.shapes[0].coordinates, edge.coordinates) + assert roi.metadata["coordinate_unit"] == "pixels" + assert roi.metadata["branch_distance"] == edge.feature_mapping()["branch_distance"] + assert not tuple(tmp_path.glob("*.swc")) + with pytest.raises(ValueError, match="explicit micrometer spacing"): + materialize( + MaterializationSpec(SWCOptions()), data=graph, + path=str(tmp_path / "forbidden.swc"), filemanager=manager, + backends=["disk"], backend_kwargs={}, + ) + assert not tuple(tmp_path.glob("*.swc")) + + +def test_new_metric_capability_executes_cooperative_hooks_without_consumer_edits(): + events = [] + + class MetricAudit: + @classmethod + def morphology_output(cls): + events.append("export-before") + result = super().morphology_output() + events.append("export-after") + return result + + def analyze(self, *args, **kwargs): + events.append("analysis-before") + result = super().analyze(*args, **kwargs) + events.append("analysis-after") + return result + + class AuditedPixelRecipe(MetricAudit, PixelCellProfilerNeuriteEngineProfile): + pass + + profile = AuditedPixelRecipe() + outputs = profile.artifact_outputs() + result = profile.analyze( + _draw_fluorescent_neuron(), + neurite_channel_index=0, + illumination=NeuriteIllumination.FLUORESCENCE, + cell_body=PixelCellBodySettings(), + outgrowth=PixelOutgrowthSettings(), + use_nuclear_stain=False, + nuclear_stain=PixelNuclearSettings(), + coordinate_spacing=profile.analysis_spacing(), + ) + assert _rows(result[1])[0]["number_of_cells"] > 0 + assert result[-1].edges + assert tuple(type(option) for option in outputs[-1].materialization.outputs) == ( + SpatialGraphROIOptions, + ) + assert events == [ + "export-before", "export-after", "analysis-before", "analysis-after" + ] + + def _rows(rows): return rows.row_mappings() @@ -117,6 +333,64 @@ def _with_separate_body_channel(neurite_stack): return np.stack((body_image, neurite_stack[0])) +def test_compact_physical_recipe_uses_declared_dimension_hooks_without_consumer_edits(): + projections = [] + + class ProjectionAudit: + def maximum_width_px(self, pixel_size_um): + projections.append(type(self)) + return super().maximum_width_px(pixel_size_um) + + class HalfWidthProjection: + def maximum_width_px(self, pixel_size_um): + return super().maximum_width_px(pixel_size_um) / 2.0 + + class QuarterAreaProjection: + def minimum_area_px(self, pixel_size_um): + return super().minimum_area_px(pixel_size_um) / 4.0 + + @dataclass(frozen=True) + class DeclaredBody( + ProjectionAudit, HalfWidthProjection, QuarterAreaProjection, + MetaXpressCellBodySettings, + ): + pass + + @dataclass(frozen=True) + class DeclaredOutgrowth( + ProjectionAudit, HalfWidthProjection, MetaXpressOutgrowthSettings + ): + pass + + image = _draw_fluorescent_neuron(branched=True) + original = image.copy() + expected = _implementation()( + image, cell_body=_cell_body_settings(), outgrowth=_outgrowth_settings(), + pixel_size=1.3556, + ) + actual = _implementation()( + image, + cell_body=DeclaredBody( + approximate_max_width=60.0, minimum_area=400.0, + intensity_above_local_background=100.0, + ), + outgrowth=DeclaredOutgrowth( + maximum_width=6.0, intensity_above_local_background=100.0, + minimum_cell_growth_to_log_as_significant=20.0, + ), + pixel_size=1.3556, + ) + np.testing.assert_array_equal(image, original) + assert actual[0] is expected[0] is image + assert _rows(actual[1]) == _rows(expected[1]) + assert _rows(actual[2]) == _rows(expected[2]) + assert _rows(actual[1])[0]["number_of_cells"] > 0 + for actual_labels, expected_labels in zip(actual[3:7], expected[3:7]): + np.testing.assert_array_equal(actual_labels, expected_labels) + assert actual[-1] == expected[-1] + assert DeclaredBody in projections and DeclaredOutgrowth in projections + + def test_signature_exposes_documented_metaxpress_controls_only(): parameters = signature(_implementation()).parameters contract = CallableContract.from_callable(neurite_outgrowth_metaxpress) @@ -435,15 +709,15 @@ def test_topology_metrics_and_significant_threshold_is_scoring_only(): assert low_summary["total_processes"] > 0 assert low_summary["total_processes"] == high_summary["total_processes"] assert low_summary["total_branches"] == high_summary["total_branches"] - assert low_cell["mean_process_length_um"] == pytest.approx( - low_cell["total_outgrowth_um"] / low_cell["processes"] + assert low_cell["mean_process_length"] == pytest.approx( + low_cell["total_outgrowth"] / low_cell["processes"] ) assert 0.0 < low_cell["straightness"] <= 1.0 assert low_cell["significant_growth"] is True assert high_cell["significant_growth"] is False assert high_summary["cells_significant_growth"] == 0 - assert high_cell["total_outgrowth_um"] == pytest.approx( - low_cell["total_outgrowth_um"] + assert high_cell["total_outgrowth"] == pytest.approx( + low_cell["total_outgrowth"] ) assert np.array_equal( low_threshold[4], @@ -468,7 +742,7 @@ def test_unrooted_crossing_arm_is_not_reported_as_a_branch(): assert summary["total_branches"] == cell["branches"] == 0 assert summary["total_processes"] == cell["processes"] > 0 assert 0.0 < cell["straightness"] <= 1.0 - assert cell["total_outgrowth_um"] > 80.0 + assert cell["total_outgrowth"] > 80.0 assert np.count_nonzero(neurite_mask[:, 75]) == 1 assert np.count_nonzero(neurite_mask[64, :]) > 80 @@ -543,7 +817,7 @@ def test_three_pixel_cycle_has_empty_topology(): topology = _analyze_topology( skeleton, np.zeros(skeleton.shape, dtype=np.int32), - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=3.0, ) @@ -583,7 +857,7 @@ def test_owned_topology_ignores_three_pixel_cycle_for_separate_owner(): topology = _analyze_owned_topology( owned, bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=3.0, ) rendered = _render_owned_skeleton(owned.shape, topology) @@ -603,7 +877,7 @@ def test_crossing_resolution_retains_two_logical_endpoint_groups(): topology = _analyze_topology( skeleton, cell_bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=3.0, ) @@ -635,7 +909,7 @@ def test_owned_geometric_crossing_uses_nominal_owner_as_branch(): topology = _analyze_owned_topology( owned, bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=2.0, ) rendered = _render_owned_skeleton(owned.shape, topology) @@ -668,7 +942,7 @@ def test_short_two_junction_crossing_resolves_opposite_rooted_traces(pixel_size_ topology = _analyze_topology( skeleton, cell_bodies, - pixel_size_um=pixel_size_um, + coordinate_scale=pixel_size_um, outgrowth_width_px=8.0, ) @@ -701,7 +975,7 @@ def test_short_two_junction_crossing_resolves_opposite_rooted_traces(pixel_size_ morphology = _build_neurite_morphology_graph( topology, cell_bodies, - pixel_size_um=pixel_size_um, + coordinate_spacing=SourceVoxelSpacing((pixel_size_um, pixel_size_um)), outgrowth_width_px=8.0, ) morphology.require_directed_forest() @@ -733,7 +1007,7 @@ def test_branch_events_require_three_paths_of_the_same_final_owner( topology = _analyze_topology( labels > 0, bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=1.0, assigned_path_labels=labels if use_assigned_owners else None, ) @@ -744,7 +1018,7 @@ def test_branch_events_require_three_paths_of_the_same_final_owner( morphology = _build_neurite_morphology_graph( topology, bodies, - pixel_size_um=1.0, + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), outgrowth_width_px=1.0, ) morphology.require_directed_forest() @@ -772,7 +1046,7 @@ def test_nearby_nonopposite_junctions_remain_a_branch_event(): topology = _analyze_topology( skeleton, cell_bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=8.0, ) @@ -798,7 +1072,7 @@ def test_neurite_morphology_is_soma_rooted_feature_bearing_forest(): assert isinstance(morphology, SpatialGraph) morphology.require_directed_forest() assert morphology.name == "neurite_morphology" - assert morphology.coordinate_spacing == (1.0, 1.0) + assert morphology.coordinate_spacing == SourceVoxelSpacing((1.0, 1.0)) assert len(morphology.roots()) == 1 assert len(morphology.edges) == len(morphology.nodes) - len(morphology.roots()) assert morphology.roots()[0].feature_mapping() == { @@ -813,20 +1087,21 @@ def test_neurite_morphology_is_soma_rooted_feature_bearing_forest(): expected_edge_features = { "label", "neuron_label", - "branch_distance_um", - "euclidean_distance_um", + "coordinate_unit", + "branch_distance", + "euclidean_distance", "tortuosity", - "distance_from_soma_um", + "distance_from_soma", "branch_type", } for edge in morphology.edges: features = edge.feature_mapping() assert set(features) == expected_edge_features assert features["label"] == features["neuron_label"] == 1 - assert features["branch_distance_um"] > 0 - assert features["euclidean_distance_um"] > 0 + assert features["branch_distance"] > 0 + assert features["euclidean_distance"] > 0 assert features["tortuosity"] >= 1.0 - assert features["distance_from_soma_um"] >= 0 + assert features["distance_from_soma"] >= 0 coordinates = np.rint(edge.coordinates).astype(int) assert not np.any(cell_bodies[tuple(coordinates.T)] > 0) @@ -856,20 +1131,20 @@ def test_cell_lengths_measure_the_published_owned_paths(branched, pixel_size): assert cells for cell in cells: owned_lengths = [ - edge.feature_mapping()["branch_distance_um"] + edge.feature_mapping()["branch_distance"] for edge in morphology.edges if edge.feature_mapping()["neuron_label"] == cell["cell"] ] assert owned_lengths - assert cell["total_outgrowth_um"] == pytest.approx(sum(owned_lengths)) - assert cell["mean_process_length_um"] == pytest.approx( - cell["total_outgrowth_um"] / cell["processes"] + assert cell["total_outgrowth"] == pytest.approx(sum(owned_lengths)) + assert cell["mean_process_length"] == pytest.approx( + cell["total_outgrowth"] / cell["processes"] ) - assert 0 <= cell["median_process_length_um"] <= cell["max_process_length_um"] - assert cell["max_process_length_um"] <= cell["total_outgrowth_um"] + assert 0 <= cell["median_process_length"] <= cell["max_process_length"] + assert cell["max_process_length"] <= cell["total_outgrowth"] # At least ceil(N / 2) nonnegative values are at least their median. - assert cell["median_process_length_um"] * ((cell["processes"] + 1) // 2) <= ( - cell["total_outgrowth_um"] + 1e-8 + assert cell["median_process_length"] * ((cell["processes"] + 1) // 2) <= ( + cell["total_outgrowth"] + 1e-8 ) @@ -919,7 +1194,7 @@ def test_neurite_morphology_breaks_cycle_without_dropping_path_geometry(): morphology = _build_neurite_morphology_graph( topology, cell_bodies, - pixel_size_um=1.0, + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), outgrowth_width_px=3.0, ) @@ -976,7 +1251,7 @@ def test_neurite_morphology_does_not_fabricate_links_between_components(): morphology = _build_neurite_morphology_graph( topology, cell_bodies, - pixel_size_um=1.0, + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), outgrowth_width_px=3.0, ) @@ -987,7 +1262,7 @@ def test_neurite_morphology_does_not_fabricate_links_between_components(): } for edge in morphology.edges: segment_lengths = np.linalg.norm(np.diff(edge.coordinates, axis=0), axis=1) - assert edge.feature_mapping()["branch_distance_um"] == pytest.approx( + assert edge.feature_mapping()["branch_distance"] == pytest.approx( float(segment_lengths.sum()) ) assert tuple(edge.coordinates[0]) in {(12.0, 12.0), (14.0, 12.0)} @@ -1016,8 +1291,8 @@ def test_disconnected_neurites_are_absent_from_owned_output_and_measurements(): assert not result[4][1, 20, 80] assert result[4][1, 64, 80] - assert _rows(result[1])[0]["total_outgrowth_um"] == pytest.approx( - _rows(baseline[1])[0]["total_outgrowth_um"] + assert _rows(result[1])[0]["total_outgrowth"] == pytest.approx( + _rows(baseline[1])[0]["total_outgrowth"] ) @@ -1372,10 +1647,12 @@ def test_transmission_mode_detects_dark_cell_and_neurite(): assert _rows(summary_rows)[0]["number_of_cells"] == 1 assert _rows(summary_rows)[0]["total_processes"] > 0 - assert _rows(cell_rows)[0]["total_outgrowth_um"] > 60.0 + assert _rows(cell_rows)[0]["total_outgrowth"] > 60.0 def test_cell_rows_do_not_remeasure_owned_paths_with_cp_seed_propagation(monkeypatch): + from importlib import import_module + image = _with_separate_body_channel(_draw_fluorescent_neuron()) def forbidden_remeasurement(*args, **kwargs): @@ -1384,7 +1661,8 @@ def forbidden_remeasurement(*args, **kwargs): ) monkeypatch.setattr( - "openhcs.processing.backends.cellprofiler.skeleton.measure_object_skeleton", + import_module("openhcs.processing.backends.cellprofiler.skeleton"), + "measure_object_skeleton", forbidden_remeasurement, ) _, summary_rows, cell_rows, _, _, _, _, *_ = _implementation()( @@ -1402,13 +1680,13 @@ def forbidden_remeasurement(*args, **kwargs): summary = _rows(summary_rows)[0] cell = _rows(cell_rows)[0] - assert cell["total_outgrowth_um"] > 60.0 + assert cell["total_outgrowth"] > 60.0 assert cell["processes"] == 1 - assert cell["mean_process_length_um"] == cell["median_process_length_um"] - assert cell["median_process_length_um"] == cell["max_process_length_um"] - assert cell["max_process_length_um"] == cell["total_outgrowth_um"] + assert cell["mean_process_length"] == cell["median_process_length"] + assert cell["median_process_length"] == cell["max_process_length"] + assert cell["max_process_length"] == cell["total_outgrowth"] assert cell["branches"] == 0 - assert summary["total_outgrowth_um"] == cell["total_outgrowth_um"] + assert summary["total_outgrowth"] == cell["total_outgrowth"] assert summary["total_processes"] == cell["processes"] assert summary["total_branches"] == cell["branches"] @@ -1495,7 +1773,7 @@ def test_nuclear_body_mode_rejects_non_neuronal_dapi_seed_and_unowned_signal(): assert {row["cell"] for row in _rows(result[2])} == {1} for row in _rows(result[2]): assert np.any(result[4][1] == row["cell"]) - assert row["total_outgrowth_um"] > 0 + assert row["total_outgrowth"] > 0 graph_pixels = np.zeros(image.shape[1:], dtype=bool) for edge in result[-1].edges: coordinates = np.rint(edge.coordinates).astype(int) @@ -1568,9 +1846,9 @@ def reject_hidden_secondary_threshold(*args, **kwargs): # The centroid-bounded soma leaves a final owned path 89 pixels long. # Independent CP seed-relative remeasurement previously reported 42 for # this same published path. - assert sorted(row["total_outgrowth_um"] for row in cell_rows) == [0.0, 89.0] + assert sorted(row["total_outgrowth"] for row in cell_rows) == [0.0, 89.0] zero_growth_cell = next( - row["cell"] for row in cell_rows if row["total_outgrowth_um"] == 0.0 + row["cell"] for row in cell_rows if row["total_outgrowth"] == 0.0 ) assert not np.any(neurites[1] == zero_growth_cell) assert zero_growth_cell not in { @@ -1841,7 +2119,7 @@ def test_secondary_ownership_adopts_logical_paths_not_whole_components(): topology = _analyze_topology( skeleton, np.zeros(skeleton.shape, dtype=np.int32), - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=2.0, ) owner_skeleton = np.zeros(skeleton.shape, dtype=np.int32) @@ -1868,7 +2146,7 @@ def test_secondary_ownership_partitions_a_path_at_nominal_owner_boundaries(): topology = _analyze_topology( skeleton, np.zeros(skeleton.shape, dtype=np.int32), - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=2.0, ) secondary_regions = np.zeros(skeleton.shape, dtype=np.int32) @@ -1896,7 +2174,7 @@ def test_owned_topology_preserves_adjacent_nominal_neuron_paths(): topology = _analyze_owned_topology( owned, bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=2.0, ) rendered = np.zeros_like(owned) @@ -1919,7 +2197,7 @@ def test_secondary_path_adoption_survives_only_with_soma_rooted_signal_support() topology = _analyze_topology( skeleton, cell_bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=2.0, ) assert not np.any(topology.path_owners) @@ -1949,7 +2227,7 @@ def repaired_topology(*, connected: bool): return repaired, _analyze_topology( repaired > 0, cell_bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=2.0, assigned_path_labels=repaired, ) @@ -2056,7 +2334,7 @@ def test_topology_discards_assigned_paths_detached_from_the_soma(): topology = _analyze_topology( labels > 0, cell_bodies, - pixel_size_um=1.0, + coordinate_scale=1.0, outgrowth_width_px=3.0, assigned_path_labels=labels, ) @@ -2108,7 +2386,7 @@ def test_morphology_retains_topology_owners_when_shared_endpoints_collide(): assert rendered[tuple(paths[1].T)].tolist() == [1, 2, 1] graph = _build_neurite_morphology_graph( - topology, bodies, pixel_size_um=1.0, outgrowth_width_px=1.0 + topology, bodies, coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), outgrowth_width_px=1.0 ) graph.require_directed_forest() assert len(graph.edges) == 2 @@ -2119,7 +2397,7 @@ def test_morphology_retains_topology_owners_when_shared_endpoints_collide(): if edge.feature_mapping()["neuron_label"] == owner ] assert len(owned_edges) == 1 - assert owned_edges[0].feature_mapping()["branch_distance_um"] == ( + assert owned_edges[0].feature_mapping()["branch_distance"] == ( pytest.approx(expected_length) ) assert np.asarray(owned_edges[0].coordinates) == pytest.approx(paths[owner - 1]) diff --git a/tests/unit/test_runtime_slice_projection.py b/tests/unit/test_runtime_slice_projection.py index a3cc39fdf8..cebe8f9008 100644 --- a/tests/unit/test_runtime_slice_projection.py +++ b/tests/unit/test_runtime_slice_projection.py @@ -60,6 +60,7 @@ ) from openhcs.core.runtime_tabular_values import ColumnarRows, FieldSpec from openhcs.core.source_image_provenance import SourceImageProvenancePlanes +from openhcs.core.source_metadata import SourceVoxelSpacing def _subclasses(root: type) -> tuple[type, ...]: @@ -157,6 +158,7 @@ def test_spatial_graph_declares_scalar_pass_through_projection() -> None: name="morphology", nodes=(SpatialGraphNode(1, (2.0, 3.0)),), edges=(), + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), ) strategy = RuntimeSliceProjectionStrategy.strategy_for_value(graph) diff --git a/tests/unit/test_selected_plane_stream_materialization.py b/tests/unit/test_selected_plane_stream_materialization.py index e65fa6d838..4394ea8c37 100644 --- a/tests/unit/test_selected_plane_stream_materialization.py +++ b/tests/unit/test_selected_plane_stream_materialization.py @@ -40,7 +40,7 @@ ) from openhcs.core.runtime_spatial_graph import SpatialGraph, SpatialGraphNode from openhcs.core.source_image_provenance import SourceImageProvenancePlanes -from openhcs.core.source_metadata import SourceVoxelSpacing +from openhcs.core.source_metadata import SourceVoxelSpacing, SourceVoxelSpacingUnit from openhcs.core.steps.function_artifact_materialization import ( PersistentArtifactMaterializationTargetPlan, @@ -248,10 +248,11 @@ def test_all_public_declared_outputs_persist_with_selected_qa_streams( image=source, labels=labels, plane_projection=projection ).payload() summary = NeuriteOutgrowthSummary( - **{field.name: 0 for field in fields(NeuriteOutgrowthSummary)} + **{field.name: 0 for field in fields(NeuriteOutgrowthSummary) if field.name != "coordinate_unit"}, + coordinate_unit=SourceVoxelSpacingUnit.MICROMETERS, ) cell = NeuriteOutgrowthCellResult( - 1, 1, 0.0, 0, 0.0, 0.0, 0.0, 0, 0.0, 1.0, 0.0, False + 1, 1, SourceVoxelSpacingUnit.MICROMETERS, 0.0, 0, 0.0, 0.0, 0.0, 0, 0.0, 1.0, 0.0, False ) selected = ImageArtifactType.contextualize_output( source, @@ -263,7 +264,7 @@ def test_all_public_declared_outputs_persist_with_selected_qa_streams( name="neurite_morphology", nodes=(SpatialGraphNode(1, (3, 4), features=(("label", 1),)),), edges=(), - coordinate_spacing=(1.3556, 1.3556), + coordinate_spacing=SourceVoxelSpacing((1.3556, 1.3556)), source_plane_index=1, source_provenance=image_payload_metadata(selected).source_provenance, ) diff --git a/tests/unit/test_spatial_graph_materialization.py b/tests/unit/test_spatial_graph_materialization.py index 18aa93f233..381f86cbc6 100644 --- a/tests/unit/test_spatial_graph_materialization.py +++ b/tests/unit/test_spatial_graph_materialization.py @@ -29,6 +29,7 @@ ) from openhcs.core.source_image_provenance import SourceImageProvenancePlanes +from openhcs.core.source_metadata import SourceVoxelSpacing, SourceVoxelSpacingUnit from openhcs.processing.materialization import ( BackendSaver, MaterializationSpec, @@ -91,7 +92,7 @@ def _branched_graph() -> SpatialGraph: neuron_label=7, ), ), - coordinate_spacing=(2.0, 3.0), + coordinate_spacing=SourceVoxelSpacing((2.0, 3.0)), ) @@ -105,6 +106,48 @@ def _outputs(graph: SpatialGraph): ) +@pytest.mark.unit +@pytest.mark.parametrize("unit", (SourceVoxelSpacingUnit.PIXELS, SourceVoxelSpacingUnit.RELATIVE)) +def test_nonphysical_analysis_graph_cannot_assert_physical_swc(unit) -> None: + graph = _branched_graph().replace_fields( + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0), unit=unit) + ) + with pytest.raises(ValueError, match="explicit micrometer spacing"): + _outputs(graph) + + +@pytest.mark.unit +def test_pixel_analysis_graph_roi_preserves_original_acquisition_calibration() -> None: + graph = _branched_graph().replace_fields( + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0), unit=SourceVoxelSpacingUnit.PIXELS), + source_plane_index=0, + ) + acquisition = ImagePayloadMetadata( + source_path="/engineering/A03_s001_w1_z002_t001.tif", + source_component_metadata={"well": "A03", "site": 1, "channel": 1, "z_index": 2, "timepoint": 1}, + source_voxel_spacing=SourceVoxelSpacing((2.0, 0.65, 0.65)), + ) + graph = SpatialGraphArtifactType.contextualize_output( + acquisition.payload_with(np.zeros((8, 8), dtype=np.uint8)), graph, None, None + ) + (output,) = materialization_outputs( + MaterializationSpec(SpatialGraphROIOptions()), data=graph, + path="/engineering/pixel-graph.roi.zip", filemanager=FileManager({"memory": MemoryStorageBackend()}), + ) + restored = ROIArchiveSourceMetadata.decode(output.content) + assert restored.source_voxel_spacing == acquisition.source_voxel_spacing + assert restored.source_provenance == acquisition.source_provenance.for_source_plane(0) + assert graph.coordinate_spacing.unit is SourceVoxelSpacingUnit.PIXELS + assert graph.source_voxel_spacing.unit is SourceVoxelSpacingUnit.MICROMETERS + assert np.array_equal(ROIArchiveSourceMetadata.geometry(output.content)[0].shapes[0].coordinates, graph.edges[0].coordinates) + + +@pytest.mark.unit +def test_graph_coordinate_unit_requires_original_nominal_spacing() -> None: + with pytest.raises(AttributeError, match="values_zyx"): + _branched_graph().replace_fields(coordinate_spacing=(1.0, 1.0)) + + @pytest.mark.unit def test_spatial_graph_is_registered_and_owns_direct_immutable_references() -> None: graph = _branched_graph() @@ -185,7 +228,7 @@ def test_spatial_graph_normalizes_mutable_node_and_edge_sequences() -> None: nodes = [root, target] edges = [edge] - graph = SpatialGraph("graph", nodes, edges) # type: ignore[arg-type] + graph = SpatialGraph("graph", nodes, edges, SourceVoxelSpacing((1.0, 1.0))) # type: ignore[arg-type] nodes.clear() edges.clear() @@ -206,7 +249,7 @@ def test_spatial_graph_rejects_nonmember_endpoint_references() -> None: ) with pytest.raises(ValueError, match="directly reference nodes"): - SpatialGraph("graph", (root, member_target), (edge,)) + SpatialGraph("graph", (root, member_target), (edge,), SourceVoxelSpacing((1.0, 1.0))) @pytest.mark.unit @@ -222,6 +265,7 @@ def test_directed_forest_validation_rejects_cycles_and_multiple_parents() -> Non _edge(2, second, third, ((0.0, 1.0), (1.0, 1.0))), _edge(3, third, first, ((1.0, 1.0), (0.0, 0.0))), ), + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), ) multiple_parents = SpatialGraph( "multiple_parents", @@ -230,6 +274,7 @@ def test_directed_forest_validation_rejects_cycles_and_multiple_parents() -> Non _edge(1, first, third, ((0.0, 0.0), (1.0, 1.0))), _edge(2, second, third, ((0.0, 1.0), (1.0, 1.0))), ), + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0)), ) with pytest.raises(ValueError, match="cycle detected"): @@ -268,7 +313,7 @@ def test_swc_reader_restores_physical_forest_and_standard_features(tmp_path) -> assert restored.name == "neurite_graph" assert restored.source_path == str(swc_path) - assert restored.coordinate_spacing == (1.0, 1.0, 1.0) + assert restored.coordinate_spacing == SourceVoxelSpacing((1.0, 1.0, 1.0)) assert tuple(node.node_id for node in restored.nodes) == tuple(range(1, 8)) assert tuple(edge.target_node_id for edge in restored.edges) == tuple(range(2, 8)) assert restored.nodes[0].coordinates == (0.0, 0.0, 0.0) @@ -468,7 +513,7 @@ def test_graph_roi_projection_rejects_three_dimensional_imagej_paths() -> None: "graph_3d", (root, target), (_edge(1, root, target, ((0.0, 0.0, 0.0), (1.0, 2.0, 3.0))),), - coordinate_spacing=(1.0, 1.0, 1.0), + coordinate_spacing=SourceVoxelSpacing((1.0, 1.0, 1.0)), ) with pytest.raises(ValueError, match="ImageJ polyline ROI archives are 2D"): diff --git a/validation/neurite-graph-unit-pr-body10.md b/validation/neurite-graph-unit-pr-body10.md new file mode 100644 index 0000000000..904d1e4098 --- /dev/null +++ b/validation/neurite-graph-unit-pr-body10.md @@ -0,0 +1,36 @@ +## Addresses #540: executable pixel-space rooted neurite workflow + +Source implementation is complete at **39df6d49c**. This PR now needs ordinary installed/public receiving, not the historical Root394 release or a missing recipe implementation. Singer owns the original unit/neurite/graph family; Planck's existing whole builder and parent receiving release own the next installed lane. Main `d5f660082` was merged normally; eight foreign gitlinks and retained evidence remain untouched. + +### What now works + +- Registered `neurite_outgrowth_metaxpress_pixels` consumes the ordinary 2D channel stack with **no physical pixel_size artifact**. Pixel-labelled body/outgrowth/nuclear settings inherit the original detection/projection hooks. +- One numerical recipe on existing `CellProfilerNeuriteEngineProfile.analyze` serves both public declarations. The old physical function's algorithm is deleted there, not copied. Common twelve-output declarations live once on that ancestor. +- Independent `PixelMetricCoordinates` composes with the recipe through genuine C3 MRO and cooperative `super()`: the original graph identity/relations survive; the pixel declaration publishes graph ROI, not physical SWC. +- Current row/edge quantities carry explicit `coordinate_unit`. **Schema cutover:** current neurite length/area/edge names are generic quantities plus declared units, not misleading `_um/_um2` labels on pixel results. Physical numerical behavior and strict calibration admission remain; separate physical soma-count rows stay unchanged. No aliases, column-renaming reader or new codec. +- Existing `SourceVoxelSpacingUnit.PIXELS` owns no physical projection. `SpatialGraph` owns analysis spacing separately from inherited acquisition calibration/provenance. Original artifact contextualization and ROI writer preserve native source calibration. Explicit pixel/relative SWC rejects before creating a file. Saved-source guards are unchanged. + +### Behavioral evidence and owner closure + +Original source batch18 **22 PASS**, including original source-plan compilation, physical/pixel masks and metric scaling, actual CSV and graph-ROI ZIP roundtrip with missing/relative/physical acquisition calibration, and independent cooperative hooks. Final affected wavelength/recipe batch21 **10 PASS**, including nuclear/non-nuclear pairs. Original physical source/recipe remainder19 **73 PASS plus one stale test-module traversal failure**, subsequently corrected at its original test owner and passing in20. Batch20 **15 PASS plus one original ACK-fixture setup error**; no assertion failure. The ordinary all-output streaming fixture remains unqualified under the existing `--noconftest` bootstrap, not a claimed green whole suite. Original Path readback, module-traversal and ACK setup negatives remain byte-exact. + +Source AST21: 702 OpenHCS/12 introspect/6 registry/17 ArrayBridge modules, zero parse omissions. Full related declaration/import/read/write/MRO evidence uses the original audit parser, not a new scanner. Original six-production-path R0-21: all authority/dispatch/type/raw-read count deltas0; five new settings/capability/recipe declarations, code+155. The earlier redundant consumer admission is deleted, not waived. Historical global NRA/R1 OOM remains an explicit proof limit. Applicable patterns IDEN-1/4, BOUND-2, IMPL-2/12; no central unit/type switch, second recipe/registry/state or wrapper-only compatibility facade. + +[Full current receipt](docs/validation/neurite-pixel-coordinate-contract-20261003.rst). +[Original negative/positive logs, source and AST/R0](docs/validation/neurite-pixel-route-qualified-20261005.tar.gz), SHA256 `74ddf4b97b84c77ee2f4bf068d28178495dd853c3a4f450a7f7286e5f0f39b53`. +[Final source21 and qualification](docs/validation/neurite-pixel-route-final-20261005.tar.gz), SHA256 `6a21ae33b04ea820f93c6cccee65ba0e577ffe1d6975f80749a0ded6b59ff9bb`. +Earlier graph/unit archives and scientific records remain intact. + +### Exact remaining acceptance + +One ordinary whole candidate must expose the registered pixel callable through MCP, compile/execute on a tiny source with no asserted physical calibration, read unit-bearing CSV/graph ROI, and reopen that saved graph at original source coordinates/native calibration. Physical calibration negatives and no pixel SWC must remain strict. The ordinary all-output streaming fixture needs its matching environment. Source execution/writer proof is **not** installed/native or biological acceptance. + +All original owned source-check handles are terminal, no UNKNOWN replay. No new build/install/native/viewer/client/provider or current scientist contact/hotpatch. CI deferred; receiving remains named and active, not a historical owner hold. + +### Current ordinary receiving handoff + +Planck's whole integration `cc1805bfd` includes main2dce374 + #541e5a7fc7, production39df unchanged. Its main-only build01 is **not** #541 installed proof. The ObjectState artifact hold is now **resolved**: Singer built/qualified exact existing v1.3.1 tag in the reused completed checkout and [published the GitHub wheel and raw proof](https://github.com/OpenHCSDev/ObjectState/releases/tag/v1.3.1). Wheel SHA `7be55a4b18326709df26b71cd4a8c7ee7456eff1db0e7fd3d676e94c6d9146ca`; 215source +215installed-originPASS/TwinePASS; all30 tagged package members/33 RECORD installed bytes match. Package/build/workflow bytes equal declaredc75a457, not an invented old-wheel identity. Original queued OIDC/PyPI publisher remains untouched, not a CI requirement. PI0.2.1/ZMQ0.4.1 are already qualified. Planck owns the one whole candidate; Dewey owns exact released native/viewer/ACK custody. No second client, old16 overlay or current SCI change. + +[Prepared public receiving packet](validation/neurite-pixel-public541/RECEIVING.rst): original tiny branched fixture, complete registered pixel document, exact CSV/ROI/native-reopen/ACK expectations, and original ACK-fixture helper closure. Syntax parsing only is qualified for the two prepared scripts; installed validation/execution remains pending. Production bytes and all archived failures are unchanged. + +Receiving17 attempt02 is now whole-installed/headless READY (cc1805bfd, all9freeze entries verified). Original ACK-fixture test **1PASS** against asserted installed OpenHCS/PolyStore/ZMQ origins, conftest enabled: all12 outputs/five exact selected batches. Source-cwd-shadow rejection22 preserved; corrected outside-source attempt23 terminal0,8.62s/452720KiB/swaps0,54warnings retained. No native listener is claimed by that nominal ACK test. Actual registered pixel MCP execution/CSV/GraphROI/native saved reopen+settlement awaits Dewey's exact known-terminal lane handoff; package/publication/CI holds are resolved. diff --git a/validation/neurite-pixel-public541/RECEIVING.rst b/validation/neurite-pixel-public541/RECEIVING.rst new file mode 100644 index 0000000000..efa6bd05fd --- /dev/null +++ b/validation/neurite-pixel-public541/RECEIVING.rst @@ -0,0 +1,111 @@ +541: ordinary installed pixel neurite receiving +=============================================== + +Singer owns acceptance. Production39df6d49c, published source1d6673d95, +integrated base d5f660082. No new production implementation is proposed here. +Planck's existing whole builder owns receiving17; its SOURCE-NEXT02.json already +names this exact541 head as pending normal integration. Dewey owns the original +recorded native/viewer launch, custody and terminal handoff. Do not install a +selective source overlay into16, change current scientists, or start a second +client. There is no current541 client/native/job/UNKNOWN request. + +Current concrete dependency +--------------------------- + +receiving17/DEPENDENCY-ARTIFACT-RECEIVING02.rst records qualified original +PythonIntrospect0.2.1 and ZMQRuntime0.4.1 wheels. ObjectState1.3.1 is missing; +its original release endpoint still returns404 at this check. Root owns that +qualified artifact handoff, already requested in ObjectState25 comment6001052886. +This is not a queued-CI gate: Root's original qualified private wheel+SHA/source +provenance suffices. Planck owns its ordinary install/integration. No receiver +dependency build, older floor, tag change, alternative codec or shared install. +Original main772 build01 and science15/16 remain immutable. + +After that handoff, integrate541 normally into the ONE ordinary candidate and +retain its whole-source/wheel/installed-byte/skill/dependency READY-FREEZE. +The target must expose neurite_outgrowth_metaxpress_pixels from the installed +OpenHCS path, not this checkout. Dewey then supplies the exact available lane, +declared acquisition/control/results roots and native/viewer/ACK incarnation. +Old closed95/6014 or94/6012 receipts do not reserve either lane for this case. +Do not submit the path-bound example until its roots match that owner projection. + +Small complete source fixture +----------------------------- + +make-source.py stages only the original branched synthetic neuron test geometry: +one scalar128x128 uint16 TIFF, values0/700/1000. No biology or references. +Invoke once in a NEW admitted acquisition root; existing files reject overwrite. +pipeline.py declares CHANNEL as runtime variable, physical stored stack axes(), +full A01/site1/channel2/z1/time1 metadata, and UNKNOWN acquisition spacing. +The pixel analysis metric is explicitly pixels; no acquisition1um assertion. +The fixture settings are unchanged existing engineering controls, not an assay +recipe. Both scripts are currently prepared, not executed/validated by installed +MCP. Any necessary path-only staging follows the original source-document owner. + +Use existing recorded-mcp.sh, bootstrap/config/session/compile/execute/status, +inventory/sample/stream/state/snapshot/close owners from the qualified receiving +family. Discover the current commands/typed arguments in the one new client; +no new harness, hard limit, timeout edit or guessed current process identity. + +Actual public acceptance +------------------------ + +1. Retain whole READY, current health and explicit owned endpoint preparation. + Reflect the installed registered pixel function: no pixel_size artifact or + parameter, common twelve outputs, morphology GraphROI only. Do not register + a replacement callable. Original physical function must still require its + existing calibrated input contract. +2. Inventory/sample the admitted raw TIFF: scalar128x128, exact source values + and metadata, no physical calibration claim. Retain its byte hash before any + execution and after closure. Create a current config/session from the complete + document, compile ONCE and observe that handle until known terminal. Execute + ONCE only after successful compile; preserve FIRST terminal STATUS and OUTCOMES + through RuntimeExecutionObservation, not a JSON reconstruction. +3. Default publication must persist all twelve artifacts. Read original CSV + rows: positive rooted geometry, explicit coordinate_unit=pixels; lengths are + pixels and areas square pixels, not mislabeled um/um2. Read original graph ROI + ZIP with load_rois_from_zip(Path(...)) and ROIArchiveSourceMetadata; source + path/component identities and unknown acquisition spacing survive. Edge + coordinate_unit=pixels and coordinates/metrics match the original persisted + typed graph. No SWC file. The physical-calibration and pixel-SWC negatives + remain strict; no assertion or guard weakening to admit the fixture. +4. Resolve saved RAW and graph ROI through public inventory and original typed + source context. Stream exactly those returned routes, not guessed filenames + or fabricated viewer receipts. Native readback must preserve acquisition + coordinates/domain and must not report a physical1um calibration. Open matched + raw/result/combined native captures personally. Retain returned source/route + identities, unit-bearing features, presentation/state and request receipts. + This proves engineering alignment/unit transport, not biological accuracy. +5. Capture execution/stream settlement ACK evidence separately from native and + client closure. Close the exact owned viewer/native ONCE through their typed + public handles, collect process_exited/ACK/listener disposition, then quit the + original CLI once and retain its numeric terminal independently. An UNKNOWN + remains observation-only; never resend the original mutation. + +Original ACK fixture qualification +--------------------------------- + +Source batch20's sole setup error was the missing viewer_ack_return_route under +the original --noconftest runner. The original fixture exists in tests/conftest.py; +it must be loaded normally, not copied into an alternative ACK engine. + +For the new matching installed candidate, stage the original conftest.py and +these THREE test modules byte-exact in a qualification directory outside any +OpenHCS source checkout: test_selected_plane_stream_materialization.py, +test_function_artifact_materialization.py, test_materialization_core.py. They +import each other's original helpers. Assert installed OpenHCS/PolyStore/ZMQ +origins, use the builder's existing dependency bootstrap and CPU-only/disabled +ImageJ download configuration, then run ONLY:: + + python -m pytest --import-mode=prepend -q \ + test_selected_plane_stream_materialization.py::test_all_public_declared_outputs_persist_with_selected_qa_streams + +Do NOT use --noconftest, inject a stand-in fixture, import foreign source into +the target or download Fiji. Preserve byte hashes and raw terminal/log outcome. +This original fixture qualifies nominal ACK-addressed streaming batches and +exact selected checkpoint pixels; it does not substitute for native ACK/reopen +acceptance above. All unchanged source18/21/AST/R0 qualification remains valid. + +No operation was dispatched while preparing this packet. Source17/19/20 failures, +all historical saved inputs/receipts, source archives and foreign gitlinks remain +unchanged. CI is deferred; whole installed receiving is the only delivery gap. diff --git a/validation/neurite-pixel-public541/make-source.py b/validation/neurite-pixel-public541/make-source.py new file mode 100644 index 0000000000..bd8ae14853 --- /dev/null +++ b/validation/neurite-pixel-public541/make-source.py @@ -0,0 +1,39 @@ +"""Stage the existing branched-neuron engineering fixture, never run analysis. + +Geometry/values are the unchanged branched case of +tests/unit/test_neurite_outgrowth.py::_draw_fluorescent_neuron. +Invoke only in the receiving owner's newly admitted acquisition directory. +""" +import argparse +from pathlib import Path + +import numpy as np +from skimage.draw import disk, line +import tifffile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("acquisition", type=Path) + args = parser.parse_args() + path = args.acquisition / "A01_s1_w2_z1_t1.tif" + if path.exists(): + raise FileExistsError(path) + image = np.zeros((128, 128), dtype=np.uint16) + rows, columns = disk((64, 20), 9, shape=image.shape) + image[rows, columns] = 1000 + for start, end in ( + ((64, 28), (64, 115)), + ((64, 75), (35, 105)), + ((64, 75), (93, 105)), + ): + rows, columns = line(*start, *end) + image[rows, columns] = 700 + args.acquisition.mkdir(parents=True, exist_ok=True) + # Scalar YX TIFF: channel is acquisition metadata, not a stored stack axis. + with path.open("xb") as stream: + tifffile.imwrite(stream, image, photometric="minisblack") + + +if __name__ == "__main__": + main() diff --git a/validation/neurite-pixel-public541/pipeline.py b/validation/neurite-pixel-public541/pipeline.py new file mode 100644 index 0000000000..d13831c88d --- /dev/null +++ b/validation/neurite-pixel-public541/pipeline.py @@ -0,0 +1,67 @@ +"""NEW public541 complete document; not submitted or installed-qualified. + +The receiving owner must admit these exact roots before staging/submission. +No old job, viewer, source receipt or runtime incarnation is reused. +""" +from pathlib import Path + +from openhcs.constants.constants import GroupBy, Microscope, VariableComponents +from openhcs.core.config import ( + LazyNapariStreamingConfig, + LazyPathPlanningConfig, + LazyProcessingConfig, + LazySourceBindingsConfig, + PipelineConfig, +) +from openhcs.core.source_bindings import ( + MetadataExtractionRule, + MetadataSource, + NamedSourceBinding, +) +from openhcs.core.source_metadata import SourceVoxelSpacing +from openhcs.core.steps.function_step import FunctionStep +from openhcs.processing.backends.analysis.neurite_outgrowth import ( + PixelCellBodySettings, + PixelOutgrowthSettings, + neurite_outgrowth_metaxpress_pixels, +) + +packet = Path( + "/home/ts/wt/openhcs-issue-batch-20260929/engineering-neurite-units-20261003/public01" +) +pipeline_config = PipelineConfig( + microscope=Microscope.SOURCE_BINDINGS, + num_workers=1, + use_threading=True, + source_bindings_config=LazySourceBindingsConfig( + metadata_rules=(MetadataExtractionRule( + MetadataSource.FILE_NAME, + r"(?PA\d+)_s(?P\d+)_w(?P\d+)_z(?P\d+)_t(?P\d+)\.tif", + ),), + bindings=(NamedSourceBinding(alias="Signal"),), + source_stack_components=(), + source_voxel_spacing=SourceVoxelSpacing(), + ), + processing_config=LazyProcessingConfig( + variable_components=[VariableComponents.CHANNEL], group_by=GroupBy.NONE, + ), + path_planning_config=LazyPathPlanningConfig( + global_output_folder=packet / "results", + ), + # Execute/persist first. Saved raw and graph reopen is a distinct public call. + napari_streaming_config=LazyNapariStreamingConfig(enabled=False), +) +pipeline_steps = [FunctionStep( + name="PixelRootedNeurites", + func=(neurite_outgrowth_metaxpress_pixels, { + "neurite_channel_index": 0, + "cell_body": PixelCellBodySettings( + approximate_max_width=30, minimum_area=100, + intensity_above_local_background=100, + ), + "outgrowth": PixelOutgrowthSettings( + maximum_width=3, intensity_above_local_background=100, + minimum_cell_growth_to_log_as_significant=20, + ), + }), +)]