From 8ad8c56c468a92eb6e7c1a7ac7abb364c18e1186 Mon Sep 17 00:00:00 2001 From: Sam Holland Date: Tue, 18 Aug 2026 14:07:45 +0100 Subject: [PATCH 1/5] fix(frames): update overall consensus weighting - Modified frames.py to implement weighting based on tilt angle, and use this when calculating the overall tilt consensus. --- src/pylisc/frames.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pylisc/frames.py b/src/pylisc/frames.py index 3d5c3f0..f9af128 100644 --- a/src/pylisc/frames.py +++ b/src/pylisc/frames.py @@ -147,16 +147,20 @@ 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}') From 78346688b812aa1013d50b2b26f5026704c79a93 Mon Sep 17 00:00:00 2001 From: Sam Holland Date: Tue, 18 Aug 2026 14:13:23 +0100 Subject: [PATCH 2/5] feat(frames): use nearest neighbour tilt fallback - Modified frames.py to implement a fall back for tilts which deviate from the consensus to use the nearest good neighbour. --- src/pylisc/frames.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/pylisc/frames.py b/src/pylisc/frames.py index f9af128..04bb0fe 100644 --- a/src/pylisc/frames.py +++ b/src/pylisc/frames.py @@ -164,9 +164,21 @@ def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold): ) 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} \ No newline at end of file + 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} From a9a65955d2e89522696d84dbeeb67f6b5a194134 Mon Sep 17 00:00:00 2001 From: Sam Holland Date: Tue, 18 Aug 2026 14:30:56 +0100 Subject: [PATCH 3/5] test(frames): add unit tests for updated frame - Added tests/unit/test_frames.py to define unit tests for tilt consensus weighting and outlier fallback. --- tests/unit/test_frames.py | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/unit/test_frames.py diff --git a/tests/unit/test_frames.py b/tests/unit/test_frames.py new file mode 100644 index 0000000..2a8bafe --- /dev/null +++ b/tests/unit/test_frames.py @@ -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) From ff8b0f250da76d1a403ab404786d43e7192e25ed Mon Sep 17 00:00:00 2001 From: Sam Holland Date: Tue, 18 Aug 2026 14:33:27 +0100 Subject: [PATCH 4/5] docs: update README for per-tilt outlier fallback - Modified README.md to add information about frame per-tilt outlier fallback behaviour. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4f7dd61..0f7e158 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. From 21b9657c99235407ab4576ccf4a974e528a2f730 Mon Sep 17 00:00:00 2001 From: Sam Holland Date: Tue, 18 Aug 2026 14:42:57 +0100 Subject: [PATCH 5/5] chore(release): update version to v2.2.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0492d2..64a1fc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/uv.lock b/uv.lock index 6210dbb..10ef717 100644 --- a/uv.lock +++ b/uv.lock @@ -321,7 +321,7 @@ wheels = [ [[package]] name = "pylisc" -version = "2.1.0" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "loguru" },