Source code for spacr.psf_measurement
"""Explicit PSF intensity selection and compatibility for quantitative Measure.
PSFs operate after Measure's normal rescaling and preprocessing hooks. Crops
retain their existing source intensities; quantitative PSF output stays float.
The captured plan, rather than a file path reopened per field, reaches workers.
"""
from __future__ import annotations
import json
from pathlib import Path
from .psf_pipeline import prepare_psf
SIGNATURE_KEY = '_psf_measurement_signature'
[docs]
def prepare_measurement_psf(settings):
"""Capture a calibrated kernel only for explicitly processed measurements.
:param settings: PSF settings plus ``psf_measurement_source``: ``original``
(default; standard Measure intensities without PSF) or ``processed``.
Processed requires convolution/deconvolution and explicit YX or ZYX
sampling. Original retains stored PSF parameters without applying them.
:returns: immutable PSFPlan or None. The caller's settings are unchanged.
:raises ValueError: for an invalid source, disabled processing when requested,
or invalid calibrated kernel settings.
"""
source = settings.get('psf_measurement_source', 'original')
if source == 'original':
return None
if source != 'processed':
raise ValueError('psf_measurement_source must be original or processed')
sampling = settings.get('psf_image_sampling_um')
ndim = len(sampling) if isinstance(sampling, (tuple, list)) else 2
plan = prepare_psf(settings, ndim=ndim)
if plan is None:
raise ValueError('Processed measurements require psf_operation '
'convolve or deconvolve')
return plan
[docs]
def measurement_psf_signature(plan):
"""Return stable JSON configuration, or None for standard intensities.
:param plan: captured PSF processing plan, or None for original intensities.
:returns: deterministically ordered JSON provenance, or None without a plan.
"""
return (json.dumps(plan.provenance(), sort_keys=True, separators=(',', ':'))
if plan is not None else None)
[docs]
def measurement_psf_record(plan, image, *, hooks=(), channels=()):
"""Describe the exact intensity stream given to quantitative features.
:param plan: captured processing plan or None for original choice.
:param image: selected intensities after standard preprocessing, before PSF.
:param hooks: names of registered standard preprocessing hooks, in order.
:param channels: original intensity-channel indices in measurement order.
:returns: JSON-safe field provenance, explicitly distinguishing crop pixels.
"""
return {
'source': 'processed' if plan is not None else 'original',
'processing': plan.provenance() if plan is not None else None,
'input_shape': list(image.shape), 'input_dtype': str(image.dtype),
'output_dtype': 'float32' if plan is not None else str(image.dtype),
'stage': 'after standard intensity rescaling and preprocessing hooks',
'preprocessing_hooks': list(hooks),
'channels': [int(channel) for channel in channels],
'input_modified': False,
'crop_intensity_source': 'source image after standard rescaling; '
'PSF and preprocessing hooks not applied',
}
[docs]
def measurement_resume_settings(settings, *, recorded=False):
"""Compare active PSF behavior rather than dormant form parameters.
:param settings: recorded or current Measure settings.
:param recorded: keep a recorded kernel identity without reopening its file.
:returns: fresh settings with inactive PSF knobs removed and one captured
material signature. Legacy measurements have the original signature None.
"""
result = {k: v for k, v in settings.items() if not k.startswith('psf_')}
if SIGNATURE_KEY not in result:
result[SIGNATURE_KEY] = (None if recorded else
measurement_psf_signature(prepare_measurement_psf(settings)))
return result
[docs]
def validate_measurement_psf_history(settings, db_path, plan):
"""Refuse to mix incompatible intensity sources even when resume is off.
:param settings: current run settings, before its database snapshot is saved.
:param db_path: measurement database; absence means a new project.
:param plan: captured plan for this run.
:raises ValueError: when existing measurement rows use another PSF identity
or cannot be proved to use the requested processed intensities.
"""
if not Path(db_path).is_file():
return
from .database_concurrency import connect
from .resume import MEASURE_OWNED_TABLES, read_recorded_settings
signature = measurement_psf_signature(plan)
message = ('Existing measurements use a different PSF intensity source '
'or kernel. Use a separate project/output database, or restore '
'the recorded PSF configuration before measuring this project.')
connection = connect(db_path, readonly=True)
try:
tables = {row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
quantitative_tables = MEASURE_OWNED_TABLES - {'png_list', 'intensity_rescale'}
occupied = any(connection.execute(
f'SELECT 1 FROM "{table}" LIMIT 1').fetchone()
for table in tables & quantitative_tables)
if not occupied:
return
recorded = read_recorded_settings(str(db_path))
previous = recorded.get(SIGNATURE_KEY)
if previous in ('None', '', 'null'):
previous = None
if SIGNATURE_KEY in recorded and previous != signature:
raise ValueError(message)
found = False
if 'intensity_rescale' in tables:
columns = {row[1] for row in connection.execute(
'PRAGMA table_info(intensity_rescale)')}
if 'psf_signature' in columns:
for (previous,) in connection.execute(
'SELECT DISTINCT psf_signature FROM intensity_rescale'):
found = True
if previous != signature:
raise ValueError(message)
if signature is not None and not found and previous != signature:
raise ValueError(message)
finally:
connection.close()