Skip to content

Commit 5166d41

Browse files
authored
Merge pull request #27 from WEHI-ResearchComputing/prepostprocess
Custom denoiser stage
2 parents a41f064 + 56dbcaa commit 5166d41

8 files changed

Lines changed: 552 additions & 7 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ RUN python -m pip install --no-cache-dir --no-cache /opt/PartiNet
88

99
LABEL AUTHORS Mihin Perera, Edward Yang, Julie Iskander
1010
LABEL MAINTAINERS Mihin Perera, Edward Yang, Julie Iskander
11-
LABEL VERSION v0.1.1
11+
LABEL VERSION v0.2.0

Singularity

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ from: python:3.9.19-slim-bookworm
1818
%labels
1919
AUTHORS Mihin Perera, Edward Yang, Julie Iskander
2020
MAINTAINERS Mihin Perera, Edward Yang, Julie Iskander
21-
VERSION v0.1.1
21+
VERSION v0.2.0

partinet/__init__.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import click
22
import sys, os
33

4-
__version__ = "0.1.1"
4+
__version__ = "0.2.0"
55

66
DYNAMICDET_AVAILABLE_MODELS = ["yolov7", "yolov7x", "yolov7-w6", "yolov7-e6", "yolov7-d6", "yolov7-e6e"]
77

@@ -60,9 +60,34 @@ def main():
6060
pass
6161

6262
@main.command()
63-
def preprocess():
64-
click.echo("This will preprocess the micrographs.")
65-
raise NotImplementedError("Not implemented yet!")
63+
@click.option("--labels", type=str, required=True, help="Path to the labels directory")
64+
@click.option("--images", type=str, required=True, help="Path to the images directory")
65+
@click.option("--output", type=str, required=True, help="Path to the output directory")
66+
def split(labels, images, output):
67+
click.echo("Splitting micrographs for training and validation...")
68+
import partinet.process_utils.split_train
69+
partinet.process_utils.split_train.main(labels, images, output)
70+
71+
@main.command()
72+
@click.option("--labels", type=str, required=True, help="Path to the labels directory")
73+
@click.option("--images", type=str, required=True, help="Path to the images directory")
74+
@click.option("--output", type=str, required=True, help="Path to the output STAR file")
75+
@click.option("--conf", type=float, default=0.0, help="Minimum confidence threshold from predictions")
76+
def star(labels, images, output,conf):
77+
click.echo("Generating STAR file...")
78+
import partinet.process_utils.star_file
79+
partinet.process_utils.star_file.main(labels,images,output,conf)
80+
81+
@main.command()
82+
@click.option("--source", type=str, required=True, help="Path to Raw micrographs")
83+
@click.option('--project', type=str, required=True, help='save denoised micrographs to project/denoised', show_default=True)
84+
@click.option('--num_workers', type=int, default=None, help='Number of workers for denoising micrographs')
85+
@click.option('--img_format', type=click.Choice(["png", "jpg", "mrc"], case_sensitive=True), default="png",show_default=True, help='Output format of denoised micrographs')
86+
def denoise(source, project, num_workers,img_format):
87+
click.echo("Denoising micrographs...")
88+
import partinet.process_utils.pooled_denoise_proc
89+
partinet.process_utils.pooled_denoise_proc.main(source,project,num_workers,img_format)
90+
6691

6792
@main.group()
6893
def train():
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Adapted from CryoSegNet
2+
# https://github.com/jianlin-cheng/CryoSegNet
3+
4+
import numpy as np
5+
import mrcfile
6+
import cv2
7+
from numpy.fft import fft2, ifft2
8+
from scipy.signal import gaussian
9+
10+
11+
def transform(image: np.ndarray) -> np.ndarray:
12+
"""
13+
Normalize and scale an image to 8-bit range (0-255).
14+
15+
Args:
16+
image (np.ndarray): Input image array.
17+
18+
Returns:
19+
np.ndarray: Normalized and scaled 8-bit image.
20+
"""
21+
i_min = image.min()
22+
i_max = image.max()
23+
image = ((image - i_min) / (i_max - i_min)) * 255
24+
return image.astype(np.uint8)
25+
26+
27+
def standard_scaler(image: np.ndarray) -> np.ndarray:
28+
"""
29+
Apply Gaussian blur and standardize the image to have zero mean and unit variance.
30+
31+
Args:
32+
image (np.ndarray): Input image array.
33+
34+
Returns:
35+
np.ndarray: Scaled and transformed image.
36+
"""
37+
kernel_size = 9
38+
image = cv2.GaussianBlur(image, (kernel_size, kernel_size), 0)
39+
mu = np.mean(image)
40+
sigma = np.std(image)
41+
image = (image - mu) / sigma
42+
return transform(image).astype(np.uint8)
43+
44+
45+
def contrast_enhancement(image: np.ndarray) -> np.ndarray:
46+
"""
47+
Enhance the contrast of the image using Non-Local Means denoising.
48+
49+
Args:
50+
image (np.ndarray): Input image array.
51+
52+
Returns:
53+
np.ndarray: Contrast-enhanced image.
54+
"""
55+
return cv2.fastNlMeansDenoising(image, None, h=10, templateWindowSize=7, searchWindowSize=21)
56+
57+
58+
def gaussian_kernel(kernel_size: int = 3) -> np.ndarray:
59+
"""
60+
Generate a 2D Gaussian kernel.
61+
62+
Args:
63+
kernel_size (int, optional): Size of the kernel. Defaults to 3.
64+
65+
Returns:
66+
np.ndarray: Gaussian kernel.
67+
"""
68+
h = gaussian(kernel_size, kernel_size / 3).reshape(kernel_size, 1)
69+
h = np.dot(h, h.transpose())
70+
h /= np.sum(h)
71+
return h
72+
73+
74+
def wiener_filter(img: np.ndarray, kernel: np.ndarray, K: float) -> np.ndarray:
75+
"""
76+
Apply Wiener filtering to the image.
77+
78+
Args:
79+
img (np.ndarray): Input image array.
80+
kernel (np.ndarray): Convolution kernel.
81+
K (float): Noise-to-signal power ratio.
82+
83+
Returns:
84+
np.ndarray: Filtered image.
85+
"""
86+
kernel /= np.sum(kernel)
87+
dummy = fft2(img)
88+
kernel = fft2(kernel, s=img.shape)
89+
kernel = np.conj(kernel) / (np.abs(kernel) ** 2 + K)
90+
dummy *= kernel
91+
return np.abs(ifft2(dummy))
92+
93+
94+
def clahe(image: np.ndarray) -> np.ndarray:
95+
"""
96+
Apply Contrast Limited Adaptive Histogram Equalization (CLAHE) to the image.
97+
98+
Args:
99+
image (np.ndarray): Input image array.
100+
101+
Returns:
102+
np.ndarray: CLAHE-processed image.
103+
"""
104+
clahe_instance = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(16, 16))
105+
return clahe_instance.apply(transform(image))
106+
107+
108+
def guided_filter(input_image: np.ndarray, guidance_image: np.ndarray, radius: int = 20, epsilon: float = 0.1) -> np.ndarray:
109+
"""
110+
Apply guided filtering to refine the image using a guidance image.
111+
112+
Args:
113+
input_image (np.ndarray): Input image array.
114+
guidance_image (np.ndarray): Guidance image array.
115+
radius (int, optional): Radius of the filter. Defaults to 20.
116+
epsilon (float, optional): Regularization parameter. Defaults to 0.1.
117+
118+
Returns:
119+
np.ndarray: Filtered image.
120+
"""
121+
input_image = input_image.astype(np.float32) / 255.0
122+
guidance_image = guidance_image.astype(np.float32) / 255.0
123+
124+
mean_guidance = cv2.boxFilter(guidance_image, -1, (radius, radius))
125+
mean_input = cv2.boxFilter(input_image, -1, (radius, radius))
126+
mean_guidance_input = cv2.boxFilter(guidance_image * input_image, -1, (radius, radius))
127+
covariance_guidance_input = mean_guidance_input - mean_guidance * mean_input
128+
129+
mean_guidance_sq = cv2.boxFilter(guidance_image * guidance_image, -1, (radius, radius))
130+
variance_guidance = mean_guidance_sq - mean_guidance * mean_guidance
131+
132+
a = covariance_guidance_input / (variance_guidance + epsilon)
133+
b = mean_input - a * mean_guidance
134+
mean_a = cv2.boxFilter(a, -1, (radius, radius))
135+
mean_b = cv2.boxFilter(b, -1, (radius, radius))
136+
137+
output_image = mean_a * guidance_image + mean_b
138+
return transform(output_image)
139+
140+
141+
def denoise(image_path: str) -> np.ndarray:
142+
"""
143+
Apply a sequence of denoising operations to an input image.
144+
145+
Args:
146+
image_path (str): Path to the input .mrc image file.
147+
148+
Returns:
149+
np.ndarray: Fully denoised image.
150+
"""
151+
kernel = gaussian_kernel(kernel_size=9)
152+
image = mrcfile.read(image_path)
153+
image = image.T
154+
image = np.rot90(image)
155+
normalized_image = standard_scaler(np.array(image))
156+
contrast_enhanced_image = contrast_enhancement(normalized_image)
157+
weiner_filtered_image = wiener_filter(contrast_enhanced_image, kernel, K=30)
158+
clahe_image = clahe(weiner_filtered_image)
159+
guided_filter_image = guided_filter(clahe_image, weiner_filtered_image)
160+
return guided_filter_image
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import os
2+
import cv2
3+
from pathlib import Path
4+
from partinet.process_utils.guided_denoiser import denoise
5+
import logging
6+
import multiprocessing
7+
import argparse
8+
import gc
9+
from concurrent.futures import ProcessPoolExecutor
10+
from typing import List, Tuple
11+
import mrcfile
12+
13+
# Function to perform CLAHE-based denoising
14+
15+
def clahe_denoise(args: Tuple[str, str, str]) -> None:
16+
"""
17+
Applies the guided denoising algorithm to an input image and saves the denoised result.
18+
19+
Args:
20+
args (Tuple[str, str]):
21+
- src_path: Path to the source micrograph file.
22+
- dest_path: Path to save the denoised image.
23+
- img_format: output format of denoised images
24+
25+
Raises:
26+
Exception: Logs any exceptions that occur during processing.
27+
"""
28+
try:
29+
src_path, dest_path, img_format = args
30+
# Perform denoising
31+
denoised = denoise(src_path)
32+
if img_format == "mrc":
33+
mrcfile.write(dest_path,data=denoised)
34+
else:
35+
cv2.imwrite(dest_path, denoised)
36+
logging.info(f"Processed image {src_path} to dest. {dest_path}")
37+
del denoised
38+
gc.collect()
39+
except Exception as e:
40+
logging.error(f"Failed to process {src_path}: {str(e)}")
41+
42+
# Function to process all files in a directory
43+
44+
def process_directory(micrographs_dir: str, clahe_denoised_dir: str, max_workers: int, img_format: str) -> None:
45+
"""
46+
Processes all `.mrc` files in the given directory using parallel workers for denoising.
47+
48+
Args:
49+
micrographs_dir (str): Path to the directory containing raw micrograph files.
50+
clahe_denoised_dir (str): Path to the directory where denoised images will be saved.
51+
max_workers (int): Number of worker processes to use for parallel processing.
52+
img_format (str): Output format of denoised images
53+
54+
Notes:
55+
- Only `.mrc` files are processed.
56+
- Existing denoised images are skipped.
57+
"""
58+
os.makedirs(clahe_denoised_dir, exist_ok=True)
59+
logging.info(f"Directory ready: {clahe_denoised_dir}")
60+
61+
tasks: List[Tuple[str, str, str]] = []
62+
# Iterate through files in the directory
63+
for file_name in os.listdir(micrographs_dir):
64+
if file_name.endswith(".mrc"):
65+
src_path = os.path.join(micrographs_dir, file_name)
66+
dest_path = os.path.join(clahe_denoised_dir, file_name.replace(".mrc", "."+img_format))
67+
if os.path.exists(dest_path):
68+
logging.info(f"{dest_path} already exists!")
69+
else:
70+
tasks.append((src_path, dest_path, img_format))
71+
72+
# Parallel processing of tasks
73+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
74+
futures = [executor.submit(clahe_denoise, task) for task in tasks]
75+
for future in futures:
76+
future.result()
77+
gc.collect()
78+
79+
# Main function
80+
81+
def main(source_dir: str, project_dir: str, ncpu: int, img_format: str) -> None:
82+
"""
83+
Main function to set up logging, determine available CPUs, and start the denoising process.
84+
85+
Args:
86+
source_dir (str): Path to the directory containing raw micrographs.
87+
project_dir (str): Path to the project directory where denoised images will be saved.
88+
ncpu (int): Number of CPUs to use for parallel processing. Defaults to half the available CPUs.
89+
img_format (str): Output format of denoised images
90+
91+
Notes:
92+
- Logging is configured to write messages to a log file and the console.
93+
- Ensures at least one CPU is used for processing.
94+
"""
95+
# Configure logging
96+
logging.basicConfig(filename="partinet_denoise.log", level=logging.INFO, format='%(asctime)s - %(message)s')
97+
logging.getLogger().addHandler(logging.StreamHandler())
98+
99+
# Prepare output directory and log file
100+
denoise_dir = os.path.join(project_dir, "denoised")
101+
logger_name = project_dir + "/partinet_denoise.log"
102+
logging.basicConfig(filename=logger_name, level=logging.INFO, format='%(asctime)s - %(message)s')
103+
104+
# Determine number of available CPUs
105+
max_available_cpus = multiprocessing.cpu_count()
106+
max_workers = max(1, max_available_cpus // 2)
107+
108+
# Adjust number of CPUs to use
109+
if ncpu is not None:
110+
ncpu = min(ncpu, max_workers)
111+
else:
112+
ncpu = max_workers
113+
114+
logging.info(f"Using {ncpu} workers out of {max_available_cpus} available CPUs.")
115+
logging.info(f"Processing raw micrographs in {source_dir}")
116+
logging.info(f"Saving denoised micrographs in {denoise_dir}")
117+
118+
# Process the directory
119+
process_directory(source_dir, denoise_dir, ncpu, img_format)
120+
121+
# Command-line argument parsing
122+
123+
def parse_args() -> argparse.Namespace:
124+
"""
125+
Parses command-line arguments for the script.
126+
127+
Returns:
128+
argparse.Namespace: Parsed arguments with the following attributes:
129+
- raw: Path to raw micrographs.
130+
- project: Path to the project directory.
131+
- ncpu: Number of CPUs to use.
132+
- img_format: output format of denoised images
133+
"""
134+
parser = argparse.ArgumentParser(description="Denoise micrographs with guided CryoSegNet-style filter")
135+
parser.add_argument("--raw", required=True, help="Path to raw micrographs")
136+
parser.add_argument("--project", required=True, help="Denoised micrographs saved in project/denoised")
137+
parser.add_argument('--ncpu', type=int, default=None, help='Number of CPUs to use')
138+
parser.add_argument('--img_format', type=str, choices=["png","jpg","mrc"], default="png", help='Output format of denoised images')
139+
return parser.parse_args()
140+
141+
# Entry point for the script
142+
143+
if __name__ == "__main__":
144+
args = parse_args()
145+
main(args.raw, args.project, args.ncpu, args.img_format)

0 commit comments

Comments
 (0)