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
66 changes: 66 additions & 0 deletions .github/workflows/pr-version-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Main Branch Pull Request Version Check

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
version-check:
name: Check version bump
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v7

- name: Read PR version
id: pr_version
run: |
VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Read main version
id: main_version
run: |
git fetch origin main --depth=1
VERSION=$(git show origin/main:pyproject.toml | grep -m1 '^version *= *' | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from main's pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d

- name: Compare versions
run: |
PR_VERSION="${{ steps.pr_version.outputs.version }}"
MAIN_VERSION="${{ steps.main_version.outputs.version }}"
echo "PR version: $PR_VERSION"
echo "main version: $MAIN_VERSION"
uv run --with packaging python3 - "$PR_VERSION" "$MAIN_VERSION" <<'EOF'
import sys
from packaging.version import Version, InvalidVersion

pr_raw, main_raw = sys.argv[1], sys.argv[2]

try:
pr = Version(pr_raw)
main = Version(main_raw)
except InvalidVersion as e:
print(f"Could not parse version as PEP 440: {e}", file=sys.stderr)
sys.exit(1)

if pr <= main:
print(f'Version in pyproject.toml ({pr_raw}) is not greater than main ({main_raw}). Versions must strictly increase.', file=sys.stderr)
sys.exit(1)

print(f'OK: {pr_raw} > {main_raw}')
EOF
40 changes: 40 additions & 0 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Create PyLisC Release

on:
push:
branches: [main]

permissions:
contents: write

jobs:
release:
name: Build and publish release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d

- name: Read version
id: version
run: |
VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Build package
run: uv build

- name: Publish release
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
generate_release_notes: true
draft: false
files: dist/*
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.0.1"
version = "2.1.0"
description = "Python implementation of LisC algorithm"
readme = "README.md"
authors = [
Expand Down
52 changes: 41 additions & 11 deletions src/pylisc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def version_callback(value: bool | None) -> None:
]
VerbosityOpt = Annotated[
int,
typer.Option('-v', '--verbose', count=True, help='Increase verbosity of logging.'),
typer.Option('-v', '--verbose', count=True, help='Increase verbosity of logging.', show_default=False, metavar=''),
]
ApplyFilterOpt = Annotated[
bool,
Expand Down Expand Up @@ -73,6 +73,18 @@ def version_callback(value: bool | None) -> None:
float,
typer.Option('--angle-outlier-threshold', help='Warn if an individual angle estimate differs from its consensus by more than this many degrees.', rich_help_panel='Batch options'),
]
ForceOpt = Annotated[
bool,
typer.Option('--force', help='Overwrite existing output files.', show_default=False)
]
DryRunOpt = Annotated[
bool,
typer.Option('--dry-run', help='Print what would be processed/written without writing any output.', show_default=False),
]
WorkersOpt = Annotated[
int,
typer.Option('--workers', help='Number of parallel processes to use in batch mode (0: all CPUs).', min=0, rich_help_panel='Batch options'),
]

# Define callback for pylisc (to allow version option)
@pylisc.callback()
Expand All @@ -97,6 +109,8 @@ def stack(
typer.Option('--output-dir', help='Output directory for batch mode (required when input_path is a directory).', rich_help_panel='Batch options')
] = None,
mode: ModeOpt = 'angular',
dry_run: DryRunOpt = False,
force: ForceOpt = False,
verbosity: VerbosityOpt = 0,
apply_filter: ApplyFilterOpt = False,
filter_threshold: FilterThresholdOpt = 5000.0,
Expand All @@ -118,27 +132,30 @@ def stack(
dc_protect_frac: DcProtectFracOpt = 0.01,
angle_outlier_threshold: AngleOutlierThresholdOpt = 5.0,
version: VersionOpt = None,
workers: WorkersOpt = 0,
):
'''
Destripe a single MRC tilt-series stack, or a directory of them.
'''
verbosity = 2 if verbosity >= 2 else verbosity
configure_logger(input_path.is_dir(), input_path, output_mrc, output_dir, verbosity)
logger.debug('running pylisc stack with: {}', ', '.join(f'{i[0]}: {i[1]}' for i in locals().items()))

if angular_width <= 0:
logger.error('angular width cannot be equal to or less than 0: {}', angular_width)
raise typer.BadParameter(f'angular width cannot be equal to or less than 0: {angular_width}')

if input_path.is_dir():
if output_dir is None:
raise typer.BadParameter('--output-dir is required when input_path is a directory')
if output_mrc is not None:
print(f'Ignoring argument: {output_mrc} (output filename)')
logger.warning('[batch mode] ignoring argument: {} (output filename)', output_mrc)
if preview_strengths is not None:
print(f'Ignoring option: --preview-strengths {preview_strengths}')
is_dir = True
logger.warning('[batch mode] ignoring option: --preview-strengths {}', preview_strengths)
else:
if output_dir is not None:
print(f'Ignoring option: --output-dir {output_dir}')
is_dir = False
verbosity = 2 if verbosity >= 2 else verbosity

configure_logger(is_dir, input_path, output_mrc, output_dir, verbosity)
logger.debug('running pylisc stack with: {}', ', '.join(f'{i[0]}: {i[1]}' for i in locals().items()))

logger.warning('[single file mode] ignoring option: --output-dir {}', output_dir)

if mode == 'linear':
logger.warning('Linear destriping mode is deprecated and may be removed in future updates. Angular destriping is more effective and is the recommended destriping mode.')

Expand All @@ -157,6 +174,9 @@ def stack(
dc_protect_frac=dc_protect_frac,
angle_outlier_threshold=angle_outlier_threshold,
preview_strengths=preview_strengths,
force=force,
dry_run=dry_run,
workers=workers,
)
logger.info('pylisc completed')
raise typer.Exit()
Expand All @@ -181,6 +201,8 @@ def frames(
typer.Option('--filename-delimiters', help='Characters that separate filename fields, see README for further information.')
] = '_',
mode: ModeOpt = 'angular',
dry_run: DryRunOpt = False,
force: ForceOpt = False,
verbosity: VerbosityOpt = 0,
apply_filter: ApplyFilterOpt = False,
filter_threshold: FilterThresholdOpt = 5000.0,
Expand All @@ -194,6 +216,7 @@ def frames(
dc_protect_frac: DcProtectFracOpt = 0.01,
angle_outlier_threshold: AngleOutlierThresholdOpt = 5.0,
version: VersionOpt = None,
workers: WorkersOpt = 0,
):
'''
Destripe a directory of 2D MRC frames.
Expand All @@ -203,6 +226,10 @@ def frames(
configure_logger(True, input_path, None, output_dir, verbosity)
logger.debug('running pylisc frames with: {}', ', '.join(f'{i[0]}: {i[1]}' for i in locals().items()))

if angular_width <= 0:
logger.error('angular width cannot be equal to or less than 0: {}', angular_width)
raise typer.BadParameter(f'angular width cannot be equal to or less than 0: {angular_width}')

if mode == 'linear':
logger.warning('Linear destriping mode is deprecated and may be removed in future updates. Angular destriping is more effective and is the recommended destriping mode.')

Expand All @@ -220,6 +247,9 @@ def frames(
notch_frac=notch_frac,
dc_protect_frac=dc_protect_frac,
angle_outlier_threshold=angle_outlier_threshold,
force=force,
dry_run=dry_run,
workers=workers,
)
logger.info('pylisc completed')
raise typer.Exit()
91 changes: 75 additions & 16 deletions src/pylisc/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
'''

# Import external libraries
import numpy as np, typer
import numpy as np, os, typer
from concurrent.futures import ProcessPoolExecutor, as_completed

# Import internal PyLisC modules
from pylisc.estimate_angle import combine_angles, estimate_curtain_angle
Expand All @@ -27,6 +28,9 @@ def run_frames(
notch_frac,
dc_protect_frac,
angle_outlier_threshold,
force,
dry_run,
workers,
):
if apply_filter and pixel_size is None:
raise typer.BadParameter('--pixel-size is required when --apply-filter is set in frames mode (frame headers are not used for pixel size)')
Expand All @@ -39,37 +43,92 @@ def run_frames(
pattern = compile_template(filename_template, delimiters=filename_delimiters)
tilt_of = {path: extract_tilt_angle(path.name, pattern) for path in paths}

output_dir.mkdir(parents=True, exist_ok=True)

if curtain_angle is None:
angle_for_path = _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold)
else:
angle_for_path = {path: curtain_angle for path in paths}

jobs = []
for path in paths:
out_path = output_dir / f'{path.stem}_PyLisC_{mode}.mrc'
with per_file_log(output_dir, out_path.stem):
data, _ = readMrcFile(path)
relative = path.relative_to(input_dir)
out_path = output_dir / relative.parent / f'{path.stem}_PyLisC_{mode}.mrc'
out_path.parent.mkdir(parents=True, exist_ok=True)
jobs.append((path, out_path, angle_for_path[path]))

max_workers = os.cpu_count()
workers = max_workers if workers == 0 else min(workers, max_workers)

logger.info('processing {} files across {} workers', len(jobs), workers)
if dry_run:
for path, out_path, _ in jobs:
logger.info('[dry-run] ({}) would write {}', path.name, out_path)
return

with ProcessPoolExecutor(max_workers=workers) as pool:
futures = {
pool.submit(
_process_one,
path,
out_path,
angle,
mode,
pixel_size,
apply_filter,
filter_threshold,
angular_width,
notch_frac,
dc_protect_frac,
force,
): path
for path, out_path, angle in jobs
}
for future in as_completed(futures):
path = futures[future]
try:
future.result()
except Exception as e:
logger.error('({}) failed during batch processing: {}', path.name, e)
logger.debug('({}) traceback:', path.name, exc_info=e)
continue
logger.debug('({}) done', path.name)

logger.info('cleared mrc files written to {}', output_dir)


def _process_one(
path,
out_path,
curtain_angle,
mode,
pixel_size,
apply_filter,
filter_threshold,
angular_width,
notch_frac,
dc_protect_frac,
force,
):
with per_file_log(out_path.parent, out_path.stem):
logger.debug('({}) starting destriping', path.name)
try:
data, voxel_size = readMrcFile(path)
cleared = lisc_clear_frame(
data[0],
decurtaining_mode=mode,
pixel_size_nm=pixel_size,
curtain_angle=angle_for_path[path],
curtain_angle=curtain_angle,
apply_filter=apply_filter,
filter_threshold_nm=filter_threshold,
angular_width_deg=angular_width,
destripe_notch_fraction=notch_frac,
dc_protect_frac=dc_protect_frac,
)
writeMrcFile(cleared[np.newaxis, ...], _read_voxel_size(path), out_path)
writeMrcFile(cleared[np.newaxis, ...], voxel_size, out_path, force)
logger.debug('({}) cleared mrc file wrote to {}', path.name, out_path)

logger.info('cleared mrc files written to {}', output_dir)


def _read_voxel_size(path):
_, voxel_size = readMrcFile(path)
return voxel_size
except Exception as e:
logger.error('({}) failed: {}', path.name, e)
logger.debug('({}) traceback: {}', path.name, exc_info=e)
raise


def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold):
Expand Down Expand Up @@ -106,4 +165,4 @@ def _estimate_per_tilt_angles(paths, tilt_of, angle_outlier_threshold):
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}
return {path: bucket_consensus[round(tilt_of[path])] for path in paths}
4 changes: 3 additions & 1 deletion src/pylisc/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ def readMrcFile(path: Path):
data = data[np.newaxis, ...]
return data, voxel_size

def writeMrcFile(data, voxel_size, path: Path):
def writeMrcFile(data, voxel_size, path: Path, force: bool = False):
if path.exists() and not force:
raise FileExistsError(f'{path} already exists: use --force to overwrite')
with mrcfile.new(path, overwrite=True) as out:
out.set_data(data.astype(np.float32))
out.voxel_size = voxel_size
Expand Down
4 changes: 2 additions & 2 deletions src/pylisc/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ def configure_logger(batch_mode, input_path, output_mrc, output_dir, verbosity):

# Set up logger
logger.remove() # remove default handler
logger.add(sys.stderr, format=LOG_FORMAT, level=log_level, colorize=True) # add terminal logger
logger.add(log_path, format=LOG_FORMAT, level=log_level) # add file logger
logger.add(sys.stderr, format=LOG_FORMAT, level=log_level, colorize=True, enqueue=True) # add terminal logger
logger.add(log_path, format=LOG_FORMAT, level=log_level, enqueue=True) # add file logger

# Logging confirmation
logger.debug('logging configured: stderr and {}, level={}', log_path, log_level)
Expand Down
Loading