Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ Option | Default | Description
Option | Default | Description
--|--|--
`--output-dir` | *(required for directory input)* | Output directory for batch mode, mirroring the input directory's structure.
`--angle-outlier-threshold` | `5.0` | Warn if an individual series' own angle estimate differs from the batch consensus by more than this many degrees.

`--angle-outlier-threshold` | `5.0` | Warn if an individual series' own angle estimate differs from the batch consensus by more than this many degrees. In `frames` mode, a tilt beyond this threshold also has its angle replaced with its nearest reliable tilt's angle, see [per-tilt curtain angle](#per-tilt-curtain-angle) for details.
#### Example
```sh
# Run PyLisC, estimating the curtaining angle automatically
Expand Down Expand Up @@ -146,7 +145,8 @@ Curtaining orientation drifts slightly with tilt angle, so unless `--angle` is g
1. Every frame's own angle is estimated.
2. Frames are grouped by tilt angle, rounded to the nearest whole degree (so e.g. two positions' `-30.00°` and `-29.98°` tilts fall in the same group).
3. Each group's estimates are combined into a per-tilt consensus (same confidence-weighted circular mean as [batch mode](#shared-curtain-angle)), which is the angle applied to every frame in that group.
4. All per-tilt consensus angles are then compared against their overall combined consensus; any that deviate by more than `--angle-outlier-threshold` are logged as a warning, the same as [batch mode's outlier detection](#outlier-detection).
4. All per-tilt consensus angles are then combined into an overall consensus, weighted both by confidence and by `cos(tilt)` (sample thickness grows ~1/cos(tilt) so high tilt angles are less reliable), so they count for less than well-sampled low-tilt groups rather than skewing the overall consensus by an equal vote.
5. Any per-tilt consensus that still deviates from the overall consensus by more than `--angle-outlier-threshold` is treated as unreliable, and will be destriped at the consensus angle of its nearest reliable tilt (by tilt-angle distance) instead, logging a warning naming both tilts. If every tilt ends up flagged, PyLisC falls back to the overall consensus for all of them.

#### Pixel size
Individual frame MRCs frequently lack a reliable pixel size in their header, so frames mode does not fall back to it. Pixel size is only needed for the optional high-pass filter — if `--apply-filter` is set, `--pixel-size` must be given explicitly, or PyLisC exits with an error.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pylisc"
version = "2.1.0"
version = "2.2.0"
description = "Python implementation of LisC algorithm"
readme = "README.md"
authors = [
Expand Down
26 changes: 21 additions & 5 deletions src/pylisc/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,22 +147,38 @@ def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold):
tilt_buckets.setdefault(bucket, []).append(path)

bucket_consensus = {}
bucket_weight = {}
for bucket, bucket_paths in tilt_buckets.items():
bucket_confidences = [confidences[p] for p in bucket_paths]
consensus, agreement = combine_angles(
[angles[p] for p in bucket_paths],
[confidences[p] for p in bucket_paths],
bucket_confidences,
)
bucket_consensus[bucket] = consensus
# High-tilt frames carry less signal (sample thickness grows ~1/cos(tilt)) so reduce weighting for overall consensus
bucket_weight[bucket] = sum(bucket_confidences) * np.cos(np.deg2rad(bucket))
logger.info('tilt {}°: consensus angle {}° (agreement: {}, n={})', bucket, f'{consensus:.1f}', f'{agreement:.3f}', len(bucket_paths))

overall_consensus, overall_agreement = combine_angles(
list(bucket_consensus.values()), [1.0] * len(bucket_consensus)
list(bucket_consensus.values()), list(bucket_weight.values())
)
logger.info('overall consensus across {} tilt angle(s): {}° (agreement: {})', len(bucket_consensus), f'{overall_consensus:.1f}', f'{overall_agreement:.3f}')

outlier_buckets = set()
for bucket, angle in bucket_consensus.items():
deviation = min(abs(angle - overall_consensus), 180 - abs(angle - overall_consensus))
if deviation > angle_outlier_threshold:
logger.warning('tilt {}° consensus angle ({}°) deviates {}° from overall consensus ({}°) - check per-tilt agreement', bucket, f'{angle:.1f}', f'{deviation:.1f}', f'{overall_consensus:.1f}')

return {path: bucket_consensus[round(tilt_of[path])] for path in paths}
outlier_buckets.add(bucket)
good_buckets = sorted(set(bucket_consensus) - outlier_buckets)
resolved_consensus = dict(bucket_consensus)
if not good_buckets:
logger.warning('every tilt bucket deviates from the overall consensus - no reliable tilt to fall back on, using overall consensus ({}°) for all', f'{overall_consensus:.1f}')
resolved_consensus = {bucket: overall_consensus for bucket in bucket_consensus}
else:
for bucket in outlier_buckets:
own_angle = bucket_consensus[bucket]
deviation = min(abs(own_angle - overall_consensus), 180 - abs(own_angle - overall_consensus))
neighbor = min(good_buckets, key=lambda g: abs(g - bucket))
resolved_consensus[bucket] = bucket_consensus[neighbor]
logger.warning('tilt {}° consensus angle ({}°) deviates {}° from overall consensus ({}°) - using nearest reliable tilt {}°\'s angle ({}°) instead', bucket, f'{own_angle:.1f}', f'{deviation:.1f}', f'{overall_consensus:.1f}', neighbor, f'{bucket_consensus[neighbor]:.1f}')
return {path: resolved_consensus[round(tilt_of[path])] for path in paths}
67 changes: 67 additions & 0 deletions tests/unit/test_frames.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'''
PyLisC: unit tests for per-tilt curtain angle consensus and outlier fallback
'''

# Import external libraries
import pytest

# Import internal functions
from pylisc.frames import _estimate_per_tilt_angles
from pylisc.log import logger
from tests.fixtures import write_synthetic_frame

class TestEstimatePerTiltAngles:
def test_overall_consensus_favors_low_tilt_over_high_tilt(self, tmp_path):
paths, tilt_of = [], {}
for i, tilt in enumerate([0, 1, 2]):
path = tmp_path / f'low_{i}.mrc'
write_synthetic_frame(path, angle_deg=20, seed=i)
paths.append(path)
tilt_of[path] = tilt
for i, tilt in enumerate([60, 61, 62]):
path = tmp_path / f'high_{i}.mrc'
write_synthetic_frame(path, angle_deg=65, seed=10 + i)
paths.append(path)
tilt_of[path] = tilt
resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=90.0)
low_tilt_consensus = resolved[tmp_path / 'low_0.mrc']
assert low_tilt_consensus == pytest.approx(20, abs=1.0)


def test_outlier_tilt_falls_back_to_nearest_reliable_neighbor(self, tmp_path):
paths, tilt_of = [], {}
# a reliable cluster around 0deg tilt, all striped at 50deg
for i, tilt in enumerate([-2, -1, 0, 1, 2]):
path = tmp_path / f'good_{i}.mrc'
write_synthetic_frame(path, angle_deg=50, seed=i)
paths.append(path)
tilt_of[path] = tilt
# a lone bad estimate at 3deg tilt, right next to the good cluster
bad_path = tmp_path / 'bad.mrc'
write_synthetic_frame(bad_path, angle_deg=-40, seed=42)
paths.append(bad_path)
tilt_of[bad_path] = 3
resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=5.0)
# the bad tilt should destripe at its nearest good neighbor's angle (~50deg), not its own different estimate (~-40deg)
assert resolved[bad_path] == pytest.approx(50, abs=2.0)


def test_all_buckets_outlier_falls_back_to_overall_consensus(self, tmp_path):
paths, tilt_of = [], {}
# two tilts that wildly disagree with each other
for i, (tilt, angle) in enumerate([(-10, 10), (10, -70)]):
path = tmp_path / f'p_{i}.mrc'
write_synthetic_frame(path, angle_deg=angle, seed=i)
paths.append(path)
tilt_of[path] = tilt

# loguru's global default sink is enqueue=True
messages = []
sink_id = logger.add(messages.append, level='WARNING')
try:
resolved = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold=1.0)
finally:
logger.remove(sink_id)

assert resolved[paths[0]] == resolved[paths[1]]
assert any('no reliable tilt' in str(m) for m in messages)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.