pycat-napari 1.5.332__tar.gz → 1.5.333__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/CHANGELOG.md +36 -0
  2. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/PKG-INFO +1 -1
  3. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/pyproject.toml +1 -1
  4. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/vpt_tools.py +364 -14
  5. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/vpt_ui.py +59 -0
  6. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/.gitignore +0 -0
  7. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/CONTRIBUTING.md +0 -0
  8. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/LICENSE +0 -0
  9. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/README.md +0 -0
  10. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/THIRD_PARTY_LICENSES.txt +0 -0
  11. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/pycat-devbio-napari-env-arm-mac.yaml +0 -0
  12. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/pycat-devbio-napari-env-x86-windows.yaml +0 -0
  13. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/pycat-napari-env-arm-mac.yaml +0 -0
  14. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/pycat-napari-env-x86-windows.yaml +0 -0
  15. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/requirements-arm-mac.txt +0 -0
  16. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/requirements-base.txt +0 -0
  17. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/requirements-devbio-napari-arm-mac.txt +0 -0
  18. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/config/requirements-devbio-napari.txt +0 -0
  19. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/Makefile +0 -0
  20. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/audits/DEV_NOTES.md +0 -0
  21. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/audits/PyCAT_method_audit_2026-07-09.md +0 -0
  22. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/audits/PyCAT_napari_integration_audit_2026-07-09.md +0 -0
  23. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/logos/pycat_logo-2.png +0 -0
  24. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/logos/pycat_logo.png +0 -0
  25. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/logos/pycat_logo_1024.png +0 -0
  26. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/logos/pycat_logo_256.png +0 -0
  27. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/logos/pycat_logo_512.png +0 -0
  28. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/make.bat +0 -0
  29. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/requirements.txt +0 -0
  30. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/dark_mode.css +0 -0
  31. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/examples/Image 1 dapi.tif +0 -0
  32. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/examples/Image 1.tif +0 -0
  33. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/pycat_logo_512.png +0 -0
  34. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/screenshots/Viewer_and_menu_highlights.png +0 -0
  35. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/screenshots/cell_analyzer.png +0 -0
  36. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/screenshots/condensate_segmentation.png +0 -0
  37. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/screenshots/measuring_lines.png +0 -0
  38. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/screenshots/opened_image_in_viewer.png +0 -0
  39. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/screenshots/preprocessed_images.png +0 -0
  40. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_static/screenshots/save_and_clear_popup.png +0 -0
  41. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/_templates/autosummary/module.rst +0 -0
  42. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/index.rst +0 -0
  43. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/pycat.central_manager.rst +0 -0
  44. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/pycat.data.rst +0 -0
  45. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/pycat.file_io.rst +0 -0
  46. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/pycat.run_pycat.rst +0 -0
  47. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/pycat.toolbox.rst +0 -0
  48. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/pycat.ui.rst +0 -0
  49. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/api/pycat.utils.rst +0 -0
  50. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/conf.py +0 -0
  51. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/development/contributing.rst +0 -0
  52. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/development/index.rst +0 -0
  53. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/development/roadmap.rst +0 -0
  54. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/development/support.rst +0 -0
  55. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/features.rst +0 -0
  56. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/index.rst +0 -0
  57. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/installation.rst +0 -0
  58. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/usage/index.rst +0 -0
  59. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/source/usage/tutorials/nuclear_condensate_analysis_gui_tutorial.rst +0 -0
  60. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/docs/test.txt +0 -0
  61. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/notebooks/Synthtic Data Generation NB.ipynb +0 -0
  62. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/notebooks/int_truncated_dfs.ipynb +0 -0
  63. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/notebooks/pycat_plotting.ipynb +0 -0
  64. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/__init__.py +0 -0
  65. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/batch_processor.py +0 -0
  66. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/batch_step_registry.py +0 -0
  67. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/central_manager.py +0 -0
  68. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/data/__init__.py +0 -0
  69. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/data/data_modules.py +0 -0
  70. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/file_io/__init__.py +0 -0
  71. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/file_io/file_io.py +0 -0
  72. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/file_io/frap_io.py +0 -0
  73. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/file_io/metadata_extract.py +0 -0
  74. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/file_io/multidim_io.py +0 -0
  75. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/file_io/session_loader.py +0 -0
  76. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/fix_tifffile.py +0 -0
  77. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/icons/pycat_logo-2.png +0 -0
  78. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/icons/pycat_logo.png +0 -0
  79. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/icons/pycat_logo_1024.png +0 -0
  80. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/icons/pycat_logo_256.png +0 -0
  81. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/icons/pycat_logo_512.png +0 -0
  82. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/run_pycat.py +0 -0
  83. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/__init__.py +0 -0
  84. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/advanced_analysis_ui.py +0 -0
  85. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/analysis_plots.py +0 -0
  86. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/batch_roi_tools.py +0 -0
  87. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/benchmark_tools.py +0 -0
  88. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/brightfield_tools.py +0 -0
  89. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/brightfield_ui.py +0 -0
  90. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/clean_spot_detection_tools.py +0 -0
  91. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/condensate_physics_tools.py +0 -0
  92. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/condensate_physics_ui.py +0 -0
  93. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/contrast_cascade_tools.py +0 -0
  94. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/contrast_cascade_ui.py +0 -0
  95. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/correlation_func_analysis_tools.py +0 -0
  96. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/data_qc_tools.py +0 -0
  97. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/data_qc_ui.py +0 -0
  98. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/data_viz_tools.py +0 -0
  99. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/dynamic_spatial_tools.py +0 -0
  100. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/fd_curve_tools.py +0 -0
  101. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/fd_curve_ui.py +0 -0
  102. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/feature_analysis_tools.py +0 -0
  103. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/fft_bandpass_tools.py +0 -0
  104. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/fibril_tools.py +0 -0
  105. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/fibril_ui.py +0 -0
  106. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/frap_tools.py +0 -0
  107. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/frap_ui.py +0 -0
  108. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/fusion_tools.py +0 -0
  109. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/fusion_ui.py +0 -0
  110. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/gaussian_localization_tools.py +0 -0
  111. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/gpu_utils.py +0 -0
  112. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/image_processing_tools.py +0 -0
  113. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/intensity_profile_tools.py +0 -0
  114. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/invitro_bf_ui.py +0 -0
  115. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/invitro_fluor_ui.py +0 -0
  116. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/invitro_tools.py +0 -0
  117. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/label_and_mask_tools.py +0 -0
  118. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/layer_tools.py +0 -0
  119. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/molecular_counting_tools.py +0 -0
  120. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/morphological_complexity_tools.py +0 -0
  121. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/nb_tools.py +0 -0
  122. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/nb_ui.py +0 -0
  123. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/numba_utils.py +0 -0
  124. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/obj_based_coloc_analysis_tools.py +0 -0
  125. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/organizational_metrics_tools.py +0 -0
  126. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/partition_enrichment_tools.py +0 -0
  127. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/pipeline_diagnostic_tools.py +0 -0
  128. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/pipeline_snr_tools.py +0 -0
  129. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/pixel_wise_corr_analysis_tools.py +0 -0
  130. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/segmentation_tools.py +0 -0
  131. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/spatial_acf_tools.py +0 -0
  132. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/spatial_metrology_tools.py +0 -0
  133. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/spatial_metrology_ui.py +0 -0
  134. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/spatial_randomness_tools.py +0 -0
  135. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/spida_tools.py +0 -0
  136. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/spida_ui.py +0 -0
  137. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/temperature_tools.py +0 -0
  138. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/temperature_ui.py +0 -0
  139. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/temporal_enhancement_tools.py +0 -0
  140. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/timeseries_condensate_tools.py +0 -0
  141. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/timeseries_invitro_fluor_ui.py +0 -0
  142. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/timeseries_invitro_tools.py +0 -0
  143. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/topology_tools.py +0 -0
  144. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/trackmate_bridge.py +0 -0
  145. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/ts_cache_manager.py +0 -0
  146. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/ts_cellpose_tools.py +0 -0
  147. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/two_channel_coloc_tools.py +0 -0
  148. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/video_export_tools.py +0 -0
  149. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/zstack_segmentation_tools.py +0 -0
  150. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/toolbox/zstack_segmentation_ui.py +0 -0
  151. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/__init__.py +0 -0
  152. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/coordinate_readout.py +0 -0
  153. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/field_status.py +0 -0
  154. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_analysis_mixin.py +0 -0
  155. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_diagnostics_mixin.py +0 -0
  156. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_filtering_mixin.py +0 -0
  157. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_imageops_mixin.py +0 -0
  158. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_labels_mixin.py +0 -0
  159. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_modules.py +0 -0
  160. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_segmentation_mixin.py +0 -0
  161. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/ui_utils.py +0 -0
  162. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/ui/workflow_checklist.py +0 -0
  163. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/utils/__init__.py +0 -0
  164. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/utils/channel_naming.py +0 -0
  165. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/utils/general_utils.py +0 -0
  166. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/utils/logging_utils.py +0 -0
  167. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/src/pycat/utils/math_utils.py +0 -0
  168. {pycat_napari-1.5.332 → pycat_napari-1.5.333}/tests/README.md +0 -0
@@ -4,6 +4,42 @@ All notable changes to PyCAT-Napari will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.5.333] - 2026-07-10
8
+ ### Added — VPT detection-variant staging + track-length histogram
9
+ - **Detection-variant staging framework.** `detect_beads_stack` gained a
10
+ `detection_variant` argument (default `'baseline'` = the 1.5.329-validated
11
+ path, byte-identical). New detection/classification approaches are opt-in
12
+ variants routed through their own branches, so the validated ~8.325-through-
13
+ TrackMate path stays selectable and any regression is a clean one-arg revert. A
14
+ `compare_detection_variants()` harness runs two variants on the same stack and
15
+ reports the classification diff, and the chosen variant is recorded on the
16
+ output DataFrame's attrs. This is the safety net for the detection rework —
17
+ every proposed change is A/B-measured against baseline before it is trusted.
18
+ - **`ring_merge` variant** (`dedup_detections_ring_merge`) — sigma-scaled merge
19
+ radius that folds DIM Airy-ring fragments into their BRIGHT centre while keeping
20
+ two genuinely-bright nearby beads as two. Built and kept in the codebase but
21
+ **not surfaced in the widget and flagged as needing validation data with
22
+ resolved Airy rings**: A/B on the current bead data showed it is a near no-op
23
+ there (beads well-separated, blob_log already ~one detection per bead). Reach it
24
+ via `detection_variant='ring_merge'`.
25
+ - **`hot_pixel_reject` variant** (`build_hot_pixel_mask` + harsher on-pixel NCC
26
+ gate) — identifies FIXED sensor hot/dead pixels from the stack's *temporal*
27
+ statistics (scene-independent: hot pixels are flat in time, tstd~3-4; beads are
28
+ variable, tstd~40-50), then applies a STRICTER acceptance test to detections
29
+ landing on them rather than a flat veto — so a real bead drifting over a hot/
30
+ dead pixel still survives on its template evidence. Validated correct and safe
31
+ (every confirmed bead survived, including one adjacent to a hot pixel). Nearly a
32
+ no-op on the current clean fluorescence data (~18 hot pixels found, blob_log
33
+ barely fires on them); earns its place on data/modes that turn hot pixels into
34
+ recurring false detections. Reach it via `detection_variant='hot_pixel_reject'`.
35
+ - **Track-length histogram in the linker widget.** Step 4 (Link Trajectories) now
36
+ shows an embedded histogram of trajectory lengths (frames per track) after each
37
+ link. A healthy link piles mass toward long tracks; a fragmentation-prone linker
38
+ shows a spike of very short tracks. The title reports the track count and the
39
+ fraction spanning ≥½ the movie, and a dashed line marks the median — an
40
+ at-a-glance linker-quality check. Fails safe if matplotlib-Qt embedding is
41
+ unavailable.
42
+
7
43
  ## [1.5.332] - 2026-07-10
8
44
  ### Fixed — VPT classifier green↔yellow flicker on bright, well-matched beads
9
45
  - **A bright, high-NCC bead no longer flips singlet↔out_of_plane frame-to-frame.**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycat-napari
3
- Version: 1.5.332
3
+ Version: 1.5.333
4
4
  Summary: Python Condensate Analysis Toolbox - A napari-based tool for biomolecular condensate analysis
5
5
  Project-URL: Homepage, https://github.com/BanerjeeLab-repertoire/pycat-napari
6
6
  Project-URL: Repository, https://github.com/BanerjeeLab-repertoire/pycat-napari
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycat-napari"
7
- version = "1.5.332"
7
+ version = "1.5.333"
8
8
  description = "Python Condensate Analysis Toolbox - A napari-based tool for biomolecular condensate analysis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12,<3.13"
@@ -340,6 +340,7 @@ def detect_beads_frame(
340
340
  fit_window: int = 9,
341
341
  fast_fit: bool = False,
342
342
  use_gpu: bool = False,
343
+ return_sigma: bool = False,
343
344
  ) -> np.ndarray:
344
345
  """
345
346
  Detect beads in a single frame via Laplacian-of-Gaussian blob detection.
@@ -381,20 +382,29 @@ def detect_beads_frame(
381
382
  num_sigma=num_sigma, threshold=threshold)
382
383
 
383
384
  if blobs.shape[0] == 0:
385
+ if return_sigma:
386
+ return np.empty((0, 2)), np.empty((0,))
384
387
  return np.empty((0, 2))
385
388
 
386
389
  coords = blobs[:, :2] # (y, x)
390
+ _sigmas = blobs[:, 2] if blobs.shape[1] >= 3 else None # detected scale
387
391
 
388
392
  if host_mask is not None:
389
393
  hm = np.asarray(host_mask) > 0
390
394
  keep = []
391
- for (y, x) in coords:
395
+ keep_sig = []
396
+ for _i, (y, x) in enumerate(coords):
392
397
  yi, xi = int(round(y)), int(round(x))
393
398
  if 0 <= yi < hm.shape[0] and 0 <= xi < hm.shape[1] and hm[yi, xi]:
394
399
  keep.append((y, x))
400
+ if _sigmas is not None:
401
+ keep_sig.append(_sigmas[_i])
395
402
  coords = np.array(keep) if keep else np.empty((0, 2))
403
+ _sigmas = (np.array(keep_sig) if keep_sig else None) if _sigmas is not None else None
396
404
 
397
405
  if not fit_quality:
406
+ if return_sigma:
407
+ return coords, _sigmas
398
408
  return coords
399
409
 
400
410
  # Per-bead 2D Gaussian quality fit
@@ -561,6 +571,211 @@ def build_airy_template(half, first_zero_px=None):
561
571
  return tmpl_z
562
572
 
563
573
 
574
+ def build_hot_pixel_mask(bead_stack, cv_max=0.12, tstd_max=8.0,
575
+ local_excess_min=8.0, median_size=5,
576
+ max_frames=None):
577
+ """Identify fixed-location sensor hot/dead pixels from a stack's TEMPORAL
578
+ statistics (detection_variant='hot_pixel_reject').
579
+
580
+ Physics. A sensor hot/dead pixel is a FIXED (r, c) whose value is set by the
581
+ detector, not the scene, so across the movie it is essentially CONSTANT in
582
+ time — high (or anomalous) temporal MEAN but very LOW temporal VARIANCE. A
583
+ real bead location has HIGH temporal variance because the bead moves through /
584
+ jitters (it comes and goes). Verified on Gable's fluorescence VPT data
585
+ (2026-07): hot pixels have temporal std ~3-4 (cv≈0.05) while bead locations
586
+ have temporal std ~40-50 (cv≈0.3-0.5) — a wide, clean gap. This temporal
587
+ signature is SCENE-INDEPENDENT, so unlike a per-frame spike test it (a)
588
+ catches hot pixels sitting DOWN NEAR THE NOISE FLOOR (this camera does this in
589
+ some modes), and (b) will not falsely reject a genuinely stable bead, which
590
+ still jitters in time. It is computed once over the stack, not per frame.
591
+
592
+ A pixel is flagged hot when ALL hold:
593
+ * it stands out from its local neighbourhood in temporal MEAN
594
+ (local-median-subtracted excess ≥ ``local_excess_min``), AND
595
+ * it is temporally FLAT — coefficient of variation
596
+ (temporal_std / temporal_mean) ≤ ``cv_max`` OR absolute temporal
597
+ std ≤ ``tstd_max``.
598
+
599
+ Parameters
600
+ ----------
601
+ bead_stack : (T, H, W) stack (lazy or array). Streamed via iter_frames so the
602
+ whole movie is never held in memory.
603
+ cv_max : max coefficient of variation for a flat (hot) pixel.
604
+ tstd_max : max absolute temporal std for a flat (hot) pixel (catches
605
+ near-noise-floor hot pixels whose mean is low so cv is less reliable).
606
+ local_excess_min : min temporal-mean excess over the local median background
607
+ to be considered anomalous at all (avoids flagging ordinary background).
608
+ median_size : neighbourhood size for the local background estimate.
609
+ max_frames : cap frames used for the statistics (None = all). A few hundred
610
+ frames are plenty to estimate the temporal signature.
611
+
612
+ Returns
613
+ -------
614
+ (H, W) boolean mask, True at hot/dead sensor pixels.
615
+
616
+ STATUS (2026-07): mechanism validated CORRECT and SAFE on Gable's fluorescence
617
+ VPT data — the temporal signature cleanly separates hot pixels (temporal std
618
+ ~3-4) from beads (temporal std ~40-50), and wired as detection_variant=
619
+ 'hot_pixel_reject' it drops hot pixels via a harsher NCC gate WITHOUT rejecting
620
+ real beads (every confirmed bead survived, including one adjacent to a hot
621
+ pixel). HOWEVER on that specific data it is nearly a no-op (~18 hot pixels
622
+ found but blob_log barely fires on them, so ~1 detection removed) — the beads
623
+ are clean and detection is already good there. It earns its place on data where
624
+ a camera/mode DOES turn hot/dead pixels into recurring false detections (e.g.
625
+ the brightfield near-noise-floor hot pixels this camera can produce). Kept and
626
+ wired, low-risk (baseline untouched); expect little effect on clean
627
+ fluorescence bead movies.
628
+ """
629
+ from pycat.file_io.file_io import iter_frames
630
+ # Streaming mean/variance (Welford) so we never hold the whole stack.
631
+ mean = None
632
+ M2 = None
633
+ n = 0
634
+ for t, frame in iter_frames(bead_stack):
635
+ f = np.asarray(frame, dtype=np.float64)
636
+ f = np.squeeze(f)
637
+ if f.ndim != 2:
638
+ continue
639
+ if mean is None:
640
+ mean = np.zeros_like(f)
641
+ M2 = np.zeros_like(f)
642
+ n += 1
643
+ delta = f - mean
644
+ mean += delta / n
645
+ M2 += delta * (f - mean)
646
+ if max_frames is not None and n >= int(max_frames):
647
+ break
648
+ if mean is None or n < 5:
649
+ # Not enough frames to estimate — flag nothing.
650
+ return np.zeros((1, 1), dtype=bool) if mean is None else \
651
+ np.zeros_like(mean, dtype=bool)
652
+ tvar = M2 / max(n - 1, 1)
653
+ tstd = np.sqrt(np.maximum(tvar, 0.0))
654
+ tmean = mean
655
+
656
+ from scipy.ndimage import median_filter
657
+ local_bg = median_filter(tmean, size=int(median_size))
658
+ excess = tmean - local_bg
659
+ cv = tstd / np.maximum(tmean, 1.0)
660
+
661
+ anomalous = excess >= float(local_excess_min)
662
+ flat = (cv <= float(cv_max)) | (tstd <= float(tstd_max))
663
+ hot = anomalous & flat
664
+ return hot
665
+
666
+
667
+ def dedup_detections_ring_merge(coords, frame, sigmas=None,
668
+ k_sigma=2.5, ring_dim_ratio=0.6,
669
+ base_radius_px=None):
670
+ """Ring-merge deduplication (detection_variant='ring_merge').
671
+
672
+ ⚠ STATUS: BUILT BUT NOT YET VALIDATED — NEEDS DATA WITH RESOLVED AIRY RINGS.
673
+ ---------------------------------------------------------------------------
674
+ A/B comparison against baseline on Gable's 2026-07 bead data (100x/~1.2 NA,
675
+ 0.67 µm/px, 200 nm beads) showed this variant is a near no-op there: the
676
+ beads are well-separated (median nearest-neighbour ~17.5 px, only ~4% within
677
+ 5 px) and blob_log already returns ~one detection per bead, so there are
678
+ essentially no ring fragments to merge (it changed ~2 of ~2000 detections).
679
+ On THAT data the real detection-quality lever is hot-pixel rejection, not
680
+ ring-merge. This function is kept because the logic is sound and there is
681
+ almost certainly a use case — data with genuinely RESOLVED Airy rings that
682
+ fire as separate blobs (denser sampling, lower NA relative to bead size, or a
683
+ lower detection threshold that picks up ring shoulders). It is deliberately
684
+ NOT exposed in the VPT widget; wire it in and validate against such a dataset
685
+ (center+ring must collapse to ONE bead, two bright peaks must stay TWO)
686
+ before trusting/surfacing it. Reach it programmatically via
687
+ detect_beads_stack(..., detection_variant='ring_merge').
688
+
689
+ Improves on ``dedup_detections`` for large, non-diffraction-limited Airy-disk
690
+ beads, where blob_log fires on both the bright CENTRE and the dim Airy RING /
691
+ multi-scale shoulders of a single bead. Two corrections over the baseline:
692
+
693
+ 1. **Self-scaling merge radius.** The merge radius is ``k_sigma × sigma`` of
694
+ the detected blob (not a fixed pixel count), so it tracks the imaged
695
+ footprint and stays correct under low NA / undersampling / astigmatism.
696
+ At 0.67 µm/px a 200 nm bead is sub-pixel, so keying off physical µm is
697
+ wrong — the detected blob sigma is the robust length scale.
698
+
699
+ 2. **Merge only the DIM companion into the BRIGHT centre; keep two bright
700
+ peaks as two beads.** A ring fragment is always the DIM companion of a
701
+ bright peak (never itself bright+compact). So a neighbour is merged into a
702
+ kept centre only if it is DIM relative to that centre
703
+ (``neighbour_intensity ≤ ring_dim_ratio × centre_intensity``). If a nearby
704
+ detection is comparably BRIGHT, it is a second real bead and is kept —
705
+ trajectory linking resolves two genuinely-separate beads far better than
706
+ detection can, and collapsing them (as the baseline does) destroys a real
707
+ track. This is the key behavioural difference from ``dedup_detections``.
708
+
709
+ Parameters
710
+ ----------
711
+ coords : list/array of (y, x).
712
+ frame : the image, used for local intensity of each detection.
713
+ sigmas : per-detection blob sigma (from blob_log column 3). If None, falls
714
+ back to ``base_radius_px`` (behaves like a fixed-radius dedup that still
715
+ respects the bright-vs-dim rule).
716
+ k_sigma : merge radius = k_sigma × sigma (default 2.5).
717
+ ring_dim_ratio : a neighbour is a mergeable ring fragment only if its local
718
+ intensity ≤ this fraction of the centre's (default 0.6). Higher = merges
719
+ more aggressively; lower = keeps more separate detections.
720
+ base_radius_px : fallback merge radius when sigmas is None.
721
+
722
+ Returns
723
+ -------
724
+ Filtered list of (y, x) — bright bead centres, with dim ring fragments folded
725
+ in and genuinely-separate bright beads preserved.
726
+ """
727
+ if coords is None or len(coords) == 0:
728
+ return coords
729
+ from scipy.spatial import cKDTree
730
+ pts = np.asarray([(float(y), float(x)) for (y, x) in coords], dtype=float)
731
+ raw = np.asarray(frame, dtype=np.float32)
732
+ raw = np.squeeze(raw)
733
+ if raw.ndim != 2:
734
+ return coords
735
+ H, W = raw.shape
736
+
737
+ def local_intensity(y, x, r=2):
738
+ yi, xi = int(round(y)), int(round(x))
739
+ y0, y1 = max(0, yi - r), min(H, yi + r + 1)
740
+ x0, x1 = max(0, xi - r), min(W, xi + r + 1)
741
+ if y1 <= y0 or x1 <= x0:
742
+ return -np.inf
743
+ return float(raw[y0:y1, x0:x1].max())
744
+
745
+ inten = np.array([local_intensity(y, x) for (y, x) in pts])
746
+ # Per-detection merge radius (sigma-scaled, or fixed fallback).
747
+ if sigmas is not None and len(sigmas) == len(pts):
748
+ radii = np.maximum(1.0, float(k_sigma) * np.asarray(sigmas, dtype=float))
749
+ elif base_radius_px:
750
+ radii = np.full(len(pts), float(base_radius_px))
751
+ else:
752
+ # No sigma and no fallback → nothing principled to merge on; keep all.
753
+ return [tuple(p) for p in pts]
754
+
755
+ tree = cKDTree(pts)
756
+ order = np.argsort(-inten) # brightest first
757
+ used = np.zeros(len(pts), dtype=bool)
758
+ kept = []
759
+ for idx in order:
760
+ if used[idx]:
761
+ continue
762
+ kept.append(idx)
763
+ centre_I = inten[idx]
764
+ # Query within this centre's radius; fold in only DIM neighbours.
765
+ neighbours = tree.query_ball_point(pts[idx], r=float(radii[idx]))
766
+ for n in neighbours:
767
+ if n == idx or used[n]:
768
+ continue
769
+ # Merge only if the neighbour is a DIM ring fragment of this centre.
770
+ # A comparably-bright neighbour is a second real bead → leave it for
771
+ # its own turn in the brightness-ordered loop (kept separately).
772
+ if inten[n] <= ring_dim_ratio * centre_I:
773
+ used[n] = True
774
+ used[idx] = True
775
+ kept.sort()
776
+ return [tuple(pts[i]) for i in kept]
777
+
778
+
564
779
  def dedup_detections(coords, frame, merge_radius_px, keep='brightest'):
565
780
  """Merge detections that fall within merge_radius_px of one another, keeping
566
781
  a single representative per cluster. blob_log can fire multiple times on one
@@ -689,11 +904,19 @@ def classify_beads(beads_df: pd.DataFrame,
689
904
  aggregate_intensity_factor: float = 1.6,
690
905
  defocus_r2_max: float = 0.85,
691
906
  sigma_outlier_factor: float = 1.5,
692
- strictness: float = 1.0) -> pd.DataFrame:
907
+ strictness: float = 1.0,
908
+ variant: str = 'baseline') -> pd.DataFrame:
693
909
  """
694
910
  Classify fitted beads into singlet / aggregate / out-of-plane using the
695
911
  2D-Gaussian quality metrics.
696
912
 
913
+ DETECTION-VARIANT STAGING (``variant``): 'baseline' is the 1.5.329-validated
914
+ classifier and is the default — it is never changed, so the validated
915
+ ~8.325-through-TrackMate path stays selectable and a revert is a one-arg
916
+ change. New variants are opt-in and additive, each implemented as its own
917
+ branch so they can be A/B-compared against baseline on the same detections
918
+ without touching the baseline code path. See ``_classify_variant_*`` helpers.
919
+
697
920
  The discriminating physics:
698
921
  - A singlet has a characteristic PSF width (sigma) and integrated
699
922
  intensity — the population modes.
@@ -770,6 +993,20 @@ def classify_beads(beads_df: pd.DataFrame,
770
993
  NCC_FLOOR = 0.55
771
994
  is_real = np.isfinite(ncc) & (ncc >= NCC_FLOOR)
772
995
 
996
+ # Hot-pixel reject variant: on a FIXED sensor hot pixel, apply a HARSHER
997
+ # acceptance test instead of a flat veto — a real bead can drift over a
998
+ # hot/dead pixel and must still be accepted if it brings genuine template
999
+ # evidence. A bare hot pixel is a flat/spiky single pixel that matches the
1000
+ # bead PSF template poorly (low NCC), so a raised NCC floor on suspect
1001
+ # pixels drops the naked hot pixel while a bead passing over (high NCC)
1002
+ # survives. Baseline is untouched (no 'on_hot_pixel' column there).
1003
+ if variant == 'hot_pixel_reject' and 'on_hot_pixel' in df.columns:
1004
+ HOT_NCC_FLOOR = 0.75 # stricter than the 0.55 baseline floor
1005
+ on_hot = df['on_hot_pixel'].fillna(False).to_numpy(dtype=bool)
1006
+ # A detection on a hot pixel must clear the higher floor to be real.
1007
+ harsh_ok = ~on_hot | (np.isfinite(ncc) & (ncc >= HOT_NCC_FLOOR))
1008
+ is_real = is_real & harsh_ok
1009
+
773
1010
  # References computed over REAL beads only (so garbage doesn't skew them).
774
1011
  rii = ii[is_real & np.isfinite(ii)]
775
1012
  ramp = amp[is_real & np.isfinite(amp)]
@@ -1077,6 +1314,7 @@ def detect_beads_stack(
1077
1314
  n_workers: Optional[int] = None,
1078
1315
  strictness: float = 1.0,
1079
1316
  use_gpu: str = 'auto',
1317
+ detection_variant: str = 'baseline',
1080
1318
  ) -> pd.DataFrame:
1081
1319
  """
1082
1320
  Detect beads across all frames of a (T, H, W) stack.
@@ -1181,8 +1419,28 @@ def detect_beads_stack(
1181
1419
  except Exception:
1182
1420
  gpu_on = False
1183
1421
 
1422
+ _variant = (detection_variant or 'baseline').lower()
1423
+ # Hot-pixel reject: build the fixed-sensor-pixel mask ONCE from the stack's
1424
+ # temporal statistics (scene-independent), then drop detections landing on
1425
+ # those pixels. Needs all frames, so it's a stack-level pre-pass.
1426
+ _hot_mask = None
1427
+ if _variant == 'hot_pixel_reject':
1428
+ try:
1429
+ _hot_mask = build_hot_pixel_mask(bead_stack)
1430
+ _n_hot = int(_hot_mask.sum()) if _hot_mask is not None else 0
1431
+ if progress_callback is None:
1432
+ print(f"[PyCAT VPT] hot_pixel_reject: flagged {_n_hot} fixed "
1433
+ f"sensor pixels from temporal statistics.")
1434
+ except Exception as _e:
1435
+ print(f"[PyCAT VPT] hot-pixel mask failed ({_e}); proceeding without.")
1436
+ _hot_mask = None
1184
1437
  precomputed_coords = None
1185
- if quality_mode == 'fast' and not gpu_on and parallel in ('auto', 'cpu', 'process'):
1438
+ # Ring-merge needs per-detection sigma from blob_log, which the parallel
1439
+ # worker path doesn't carry, so run detection serially for that variant.
1440
+ # Hot-pixel reject also runs serially (it filters coords against the mask).
1441
+ if quality_mode == 'fast' and not gpu_on \
1442
+ and _variant not in ('ring_merge', 'hot_pixel_reject') \
1443
+ and parallel in ('auto', 'cpu', 'process'):
1186
1444
  src_desc = _bead_source_descriptor(bead_stack)
1187
1445
  try:
1188
1446
  import os as _os
@@ -1236,14 +1494,24 @@ def detect_beads_stack(
1236
1494
  # template building + scoring (both cheap).
1237
1495
  coords = precomputed_coords[t]
1238
1496
  else:
1239
- coords = detect_beads_frame(
1240
- frame, min_sigma=min_sigma, max_sigma=max_sigma,
1241
- num_sigma=num_sigma, threshold=threshold, host_mask=host_mask,
1242
- use_gpu=gpu_on)
1243
- # De-duplicate multi-scale / ring detections on a single bead, if
1244
- # a merge radius is set (from the physical bead size upstream).
1245
- if merge_radius_px:
1246
- coords = dedup_detections(coords, frame, merge_radius_px)
1497
+ if _variant == 'ring_merge':
1498
+ coords, _sig = detect_beads_frame(
1499
+ frame, min_sigma=min_sigma, max_sigma=max_sigma,
1500
+ num_sigma=num_sigma, threshold=threshold,
1501
+ host_mask=host_mask, use_gpu=gpu_on, return_sigma=True)
1502
+ # Ring-merge: sigma-scaled radius, merge dim ring fragments
1503
+ # into bright centres, keep two bright beads as two.
1504
+ coords = dedup_detections_ring_merge(
1505
+ coords, frame, sigmas=_sig,
1506
+ base_radius_px=merge_radius_px)
1507
+ else:
1508
+ coords = detect_beads_frame(
1509
+ frame, min_sigma=min_sigma, max_sigma=max_sigma,
1510
+ num_sigma=num_sigma, threshold=threshold,
1511
+ host_mask=host_mask, use_gpu=gpu_on)
1512
+ # Baseline de-dup (fixed radius, keep brightest per cluster).
1513
+ if merge_radius_px:
1514
+ coords = dedup_detections(coords, frame, merge_radius_px)
1247
1515
  if template_z is None or template_mode == 'per_frame':
1248
1516
  if template_type == 'airy':
1249
1517
  # Analytic Airy (Bessel J1) template — matches beads that
@@ -1256,14 +1524,23 @@ def detect_beads_stack(
1256
1524
  scored = score_beads_template(frame, coords, template_z,
1257
1525
  half=half, subpixel=subpixel)
1258
1526
  for i, b in enumerate(scored):
1259
- rows.append({
1527
+ _row = {
1260
1528
  'frame': t, 'object_id': i,
1261
1529
  'y_um': float(b['y']) * microns_per_pixel,
1262
1530
  'x_um': float(b['x']) * microns_per_pixel,
1263
1531
  'area_um2': nominal_area,
1264
1532
  'ncc': b['ncc'], 'snr': b['snr'], 'symmetry': b['symmetry'],
1265
1533
  'amplitude': b['amplitude'],
1266
- 'integrated_intensity': b['integrated_intensity']})
1534
+ 'integrated_intensity': b['integrated_intensity']}
1535
+ # Flag detections sitting on a fixed sensor hot pixel so the
1536
+ # classifier can apply a HARSHER acceptance test there (not a flat
1537
+ # reject — a real bead can drift over a hot/dead pixel and should
1538
+ # still be accepted if it brings real PSF/template evidence).
1539
+ if _hot_mask is not None:
1540
+ yi = int(round(b['y'])); xi = int(round(b['x']))
1541
+ if 0 <= yi < _hot_mask.shape[0] and 0 <= xi < _hot_mask.shape[1]:
1542
+ _row['on_hot_pixel'] = bool(_hot_mask[yi, xi])
1543
+ rows.append(_row)
1267
1544
  else:
1268
1545
  beads = detect_beads_frame(
1269
1546
  frame, min_sigma=min_sigma, max_sigma=max_sigma,
@@ -1308,7 +1585,13 @@ def detect_beads_stack(
1308
1585
  return pd.DataFrame(columns=cols)
1309
1586
 
1310
1587
  df = pd.DataFrame(rows)
1311
- df = classify_beads(df, strictness=strictness)
1588
+ # ── Detection-variant staging ────────────────────────────────────────────
1589
+ # 'baseline' = the 1.5.329-validated classifier (recovers ~8.325 through
1590
+ # TrackMate). New variants are OPT-IN and additive so the validated path
1591
+ # stays selectable and a revert is a clean single-arg change. The variant is
1592
+ # recorded on the frame for auditability and downstream comparison.
1593
+ df = classify_beads(df, strictness=strictness, variant=_variant)
1594
+ df.attrs['detection_variant'] = _variant
1312
1595
 
1313
1596
  if exclude_aggregates:
1314
1597
  df = df[df['bead_class'] != 'aggregate'].reset_index(drop=True)
@@ -1770,3 +2053,70 @@ def _link(detections, linker, max_dist_um, max_gap, mpp, progress_callback=None)
1770
2053
  progress_callback=progress_callback)
1771
2054
  from pycat.toolbox.dynamic_spatial_tools import link_trajectories
1772
2055
  return link_trajectories(detections, max_dist_um, max_gap)
2056
+
2057
+
2058
+ def compare_detection_variants(
2059
+ bead_stack,
2060
+ variant_a: str = 'baseline',
2061
+ variant_b: str = 'baseline',
2062
+ microns_per_pixel: float = 1.0,
2063
+ max_frames: Optional[int] = None,
2064
+ **detect_kwargs,
2065
+ ) -> dict:
2066
+ """
2067
+ Run bead detection under TWO variants on the SAME stack and report how the
2068
+ detections/classifications differ. This is the staging safety net for the
2069
+ detection rework: every proposed change is measured against the
2070
+ 1.5.329-validated baseline before it is trusted, and a regression is visible
2071
+ immediately rather than only surfacing in the final viscosity.
2072
+
2073
+ Parameters
2074
+ ----------
2075
+ bead_stack : (T, H, W) stack (lazy or array).
2076
+ variant_a, variant_b : detection_variant names to compare (default both
2077
+ 'baseline'; pass e.g. variant_b='ring_merge' to A/B a new variant).
2078
+ microns_per_pixel : pixel size (passed through to detection).
2079
+ max_frames : cap the number of frames for a fast comparison (None = all).
2080
+ **detect_kwargs : forwarded to detect_beads_stack (quality_mode, parallel…).
2081
+
2082
+ Returns
2083
+ -------
2084
+ dict with:
2085
+ counts_a / counts_b : total detections per variant
2086
+ class_counts_a / _b : bead_class value-counts per variant
2087
+ n_frames : frames compared
2088
+ summary : human-readable one-line diff
2089
+ det_a / det_b : the two detection DataFrames (for deeper analysis)
2090
+ """
2091
+ import numpy as np
2092
+ frame_indices = None
2093
+ if max_frames is not None:
2094
+ try:
2095
+ T = int(np.asarray(bead_stack).shape[0]) if not hasattr(bead_stack, 'shape') \
2096
+ else int(bead_stack.shape[0])
2097
+ frame_indices = list(range(min(T, int(max_frames))))
2098
+ except Exception:
2099
+ frame_indices = None
2100
+
2101
+ det_a = detect_beads_stack(
2102
+ bead_stack, microns_per_pixel=microns_per_pixel,
2103
+ frame_indices=frame_indices, detection_variant=variant_a, **detect_kwargs)
2104
+ det_b = detect_beads_stack(
2105
+ bead_stack, microns_per_pixel=microns_per_pixel,
2106
+ frame_indices=frame_indices, detection_variant=variant_b, **detect_kwargs)
2107
+
2108
+ cc_a = (det_a['bead_class'].value_counts().to_dict()
2109
+ if 'bead_class' in det_a else {})
2110
+ cc_b = (det_b['bead_class'].value_counts().to_dict()
2111
+ if 'bead_class' in det_b else {})
2112
+ n_frames = int(det_a['frame'].nunique()) if 'frame' in det_a else 0
2113
+
2114
+ summary = (
2115
+ f"[{variant_a}] {len(det_a)} dets {cc_a} vs "
2116
+ f"[{variant_b}] {len(det_b)} dets {cc_b} over {n_frames} frames")
2117
+
2118
+ return dict(
2119
+ counts_a=len(det_a), counts_b=len(det_b),
2120
+ class_counts_a=cc_a, class_counts_b=cc_b,
2121
+ n_frames=n_frames, summary=summary,
2122
+ det_a=det_a, det_b=det_b)
@@ -825,6 +825,24 @@ class VideoParticleTrackingUI:
825
825
  btn.clicked.connect(self._on_link)
826
826
  form.addRow(self._track_prog); from pycat.ui.field_status import button_with_circle as _bwc
827
827
  form.addRow(_bwc(btn))
828
+
829
+ # Embedded track-length histogram — shows the distribution of trajectory
830
+ # lengths (in frames) after linking. A healthy result has many long
831
+ # tracks (mass toward the right / spanning most of the movie); a
832
+ # fragmentation-prone linker shows a spike of very short tracks. This is
833
+ # useful every run, and doubles as an at-a-glance linker-quality check.
834
+ try:
835
+ from matplotlib.figure import Figure
836
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
837
+ self._tracklen_fig = Figure(figsize=(3.2, 1.7), tight_layout=True)
838
+ self._tracklen_canvas = FigureCanvasQTAgg(self._tracklen_fig)
839
+ self._tracklen_canvas.setMinimumHeight(150)
840
+ self._tracklen_canvas.setVisible(False) # shown after first link
841
+ form.addRow(self._tracklen_canvas)
842
+ except Exception:
843
+ self._tracklen_fig = None
844
+ self._tracklen_canvas = None
845
+
828
846
  layout.addWidget(grp)
829
847
 
830
848
  def _linker_name(self):
@@ -974,6 +992,7 @@ class VideoParticleTrackingUI:
974
992
  f"(drift-corrected) from the {pop_which} population.")
975
993
  if n_agg_tracks:
976
994
  msg += f" Aggregate population: {n_agg_tracks} tracks."
995
+ self._update_tracklen_hist(tracks)
977
996
  napari_show_info(msg)
978
997
  def _err(msg):
979
998
  self._track_prog.setVisible(False)
@@ -982,6 +1001,46 @@ class VideoParticleTrackingUI:
982
1001
  w.progress.connect(lambda i, n: self._track_prog.setValue(i))
983
1002
  self._track_worker = w; w.start()
984
1003
 
1004
+ def _update_tracklen_hist(self, tracks):
1005
+ """Draw the track-length (frames-per-track) histogram in the linker
1006
+ widget. A healthy link has many long tracks; a fragmentation-prone
1007
+ linker piles up very short ones. Called after each link."""
1008
+ if getattr(self, '_tracklen_canvas', None) is None \
1009
+ or getattr(self, '_tracklen_fig', None) is None:
1010
+ return
1011
+ try:
1012
+ if tracks is None or 'track_id' not in tracks or tracks.empty:
1013
+ return
1014
+ lengths = tracks.groupby('track_id').size().values
1015
+ n_frames = int(tracks['frame'].nunique()) if 'frame' in tracks else 0
1016
+ fig = self._tracklen_fig
1017
+ fig.clear()
1018
+ ax = fig.add_subplot(111)
1019
+ import numpy as _np
1020
+ Lmax = int(lengths.max()) if len(lengths) else 1
1021
+ nbins = int(_np.clip(Lmax, 5, 40))
1022
+ ax.hist(lengths, bins=nbins, color='#4c72b0',
1023
+ edgecolor='#2b4a72', linewidth=0.4)
1024
+ med = float(_np.median(lengths)) if len(lengths) else 0.0
1025
+ ax.axvline(med, color='#c44e52', ls='--', lw=1,
1026
+ label=f"median {med:.0f}")
1027
+ # Fraction of tracks spanning most of the movie — the quality signal.
1028
+ if n_frames > 0:
1029
+ frac_long = float(_np.mean(lengths >= 0.5 * n_frames))
1030
+ ax.set_title(
1031
+ f"{len(lengths)} tracks · {frac_long*100:.0f}% span ≥½ movie",
1032
+ fontsize=8)
1033
+ else:
1034
+ ax.set_title(f"{len(lengths)} tracks", fontsize=8)
1035
+ ax.set_xlabel("track length (frames)", fontsize=8)
1036
+ ax.set_ylabel("count", fontsize=8)
1037
+ ax.tick_params(labelsize=7)
1038
+ ax.legend(fontsize=7, frameon=False)
1039
+ self._tracklen_canvas.setVisible(True)
1040
+ self._tracklen_canvas.draw_idle()
1041
+ except Exception as _e:
1042
+ print(f"[PyCAT VPT] track-length histogram skipped: {_e}")
1043
+
985
1044
  # ── Step 5: microrheology ──────────────────────────────────────────
986
1045
  def _add_microrheology(self, layout):
987
1046
  grp = QGroupBox("Step 5 — Microrheology (MSD → Viscosity)")
File without changes
File without changes