11import os
22import shutil
33import argparse
4-
4+ import cv2
55import numpy as np
6+ from typing import Dict , List
67from sklearn .model_selection import train_test_split as tts
78
8- def main (labels_path : str , images_path : str , output_dir : str ):
9+
10+ def parse_star_file (star_path : str ) -> List [Dict [str , str ]]:
11+ """
12+ Parse a STAR file and extract particle coordinates
13+ Returns a list of dictionaries with micrograph names and coordinates
14+ """
15+ particles = []
16+ in_data_section = False
17+ headers = []
18+ header_indices = {}
19+
20+ with open (star_path , 'r' ) as f :
21+ for line in f :
22+ line = line .strip ()
23+
24+ # Skip empty lines and comments
25+ if not line or line .startswith ('#' ):
26+ continue
27+
28+ # Check for data section
29+ if line .startswith ('data_' ):
30+ in_data_section = True
31+ continue
32+
33+ # Check for loop section
34+ if line .startswith ('loop_' ):
35+ continue
36+
37+ # Parse headers
38+ if line .startswith ('_rln' ):
39+ parts = line .split ()
40+ header_name = parts [0 ]
41+ if len (parts ) > 1 :
42+ header_idx = int (parts [1 ].replace ('#' , '' )) - 1 # Convert to 0-indexed
43+ headers .append (header_name )
44+ header_indices [header_name ] = header_idx
45+ continue
46+
47+ # Parse data rows
48+ if in_data_section and headers :
49+ parts = line .split ()
50+ if len (parts ) >= len (headers ):
51+ particle = {}
52+ for header in headers :
53+ idx = header_indices [header ]
54+ particle [header ] = parts [idx ]
55+ particles .append (particle )
56+
57+ return particles
58+
59+
60+ def starfile_to_yolo (x_coord : int , y_coord : int , diameter : int ,
61+ image_width : int , image_height : int , class_id : int = 0 ) -> Dict [str , float ]:
62+ """
63+ Convert STAR file coordinates to YOLO format
64+ """
65+ # Calculate YOLO normalized coordinates
66+ x_center = x_coord / image_width
67+ y_center = y_coord / image_height
68+ width = diameter / image_width
69+ height = diameter / image_height
70+
71+ return {
72+ 'class' : class_id ,
73+ 'x_center' : x_center ,
74+ 'y_center' : y_center ,
75+ 'width' : width ,
76+ 'height' : height
77+ }
78+
79+
80+ def group_particles_by_micrograph (particles : List [Dict [str , str ]]) -> Dict [str , List [Dict [str , str ]]]:
81+ """
82+ Group particles by their micrograph name
83+ """
84+ grouped = {}
85+ for particle in particles :
86+ micrograph = particle .get ('_rlnMicrographName' , '' )
87+ if micrograph not in grouped :
88+ grouped [micrograph ] = []
89+ grouped [micrograph ].append (particle )
90+ return grouped
91+
92+
93+ def convert_star_to_yolo (star_path : str , images_path : str , output_labels_path : str , class_id : int = 0 ) -> List [str ]:
94+ """
95+ Convert STAR file to YOLO format labels
96+
97+ Args:
98+ star_path: Path to input STAR file
99+ images_path: Path to directory containing micrograph images
100+ output_labels_path: Path to output directory for YOLO label files
101+ class_id: Class ID to assign to all particles (default: 0)
102+
103+ Returns:
104+ List of processed filenames (without extensions)
105+ """
106+ # Create output directory if it doesn't exist
107+ os .makedirs (output_labels_path , exist_ok = True )
108+
109+ # Parse STAR file
110+ print (f"Parsing STAR file: { star_path } " )
111+ particles = parse_star_file (star_path )
112+ print (f"Found { len (particles )} particles" )
113+
114+ # Group particles by micrograph
115+ grouped_particles = group_particles_by_micrograph (particles )
116+ print (f"Found { len (grouped_particles )} unique micrographs" )
117+
118+ processed_files = []
119+
120+ # Process each micrograph
121+ for micrograph_name , micrograph_particles in grouped_particles .items ():
122+ # Remove .mrc extension and get base filename
123+ base_filename = os .path .splitext (os .path .basename (micrograph_name ))[0 ]
124+
125+ # Find corresponding image file (try common extensions)
126+ image_file = None
127+ image_ext = None
128+ for ext in ['.png' , '.jpg' , '.jpeg' , '.tif' , '.tiff' , '.mrc' ]:
129+ potential_path = os .path .join (images_path , base_filename + ext )
130+ if os .path .exists (potential_path ):
131+ image_file = potential_path
132+ image_ext = ext
133+ break
134+
135+ if image_file is None :
136+ print (f"Warning: Image file not found for { base_filename } . Skipping." )
137+ continue
138+
139+ # Read image to get dimensions
140+ image = cv2 .imread (image_file )
141+ if image is None :
142+ print (f"Warning: Could not read image { image_file } . Skipping." )
143+ continue
144+
145+ img_height , img_width = image .shape [:2 ]
146+
147+ # Convert particles to YOLO format
148+ yolo_labels = []
149+ for particle in micrograph_particles :
150+ x_coord = int (particle .get ('_rlnCoordinateX' , 0 ))
151+ y_coord = int (particle .get ('_rlnCoordinateY' , 0 ))
152+ diameter = int (particle .get ('_rlnDiameter' , 0 ))
153+
154+ yolo_coords = starfile_to_yolo (x_coord , y_coord , diameter ,
155+ img_width , img_height , class_id )
156+ yolo_labels .append (yolo_coords )
157+
158+ # Write YOLO label file
159+ output_file = os .path .join (output_labels_path , f"{ base_filename } .txt" )
160+ with open (output_file , 'w' ) as f :
161+ for label in yolo_labels :
162+ f .write (f"{ label ['class' ]} { label ['x_center' ]:.6f} { label ['y_center' ]:.6f} "
163+ f"{ label ['width' ]:.6f} { label ['height' ]:.6f} \n " )
164+
165+ processed_files .append ((base_filename , image_ext ))
166+ print (f"Processed { base_filename } : { len (yolo_labels )} particles" )
167+
168+ print (f"\n Conversion complete! Labels saved to { output_labels_path } " )
169+ return processed_files
170+
171+
172+ def split_train_val (labels_path : str , images_path : str , output_dir : str , test_size : float = 0.25 ):
9173 """
10174 Splits a dataset of images and labels into training and validation sets and organizes them into
11175 a specified output directory. Generates corresponding .txt files for train/val data and a
12176 cryo_training.yaml file for use in model training.
13177
14178 Args:
15- labels_path (str): Path to the directory containing label files.
16- images_path (str): Path to the directory containing image files.
17- output_dir (str): Path to the output directory where split data will be saved.
179+ labels_path: Path to the directory containing label files
180+ images_path: Path to the directory containing image files
181+ output_dir: Path to the output directory where split data will be saved
182+ test_size: Proportion of dataset to use for validation (default: 0.25)
18183 """
19184 # Create output directories if they do not exist
20185 if not os .path .exists (output_dir ):
21186 os .makedirs (output_dir )
22- os .makedirs (os .path .join (output_dir , "images" , "train" ))
23- os .makedirs (os .path .join (output_dir , "images" , "val" ))
24- os .makedirs (os .path .join (output_dir , "labels" , "train" ))
25- os .makedirs (os .path .join (output_dir , "labels" , "val" ))
187+ os .makedirs (os .path .join (output_dir , "images" , "train" ), exist_ok = True )
188+ os .makedirs (os .path .join (output_dir , "images" , "val" ), exist_ok = True )
189+ os .makedirs (os .path .join (output_dir , "labels" , "train" ), exist_ok = True )
190+ os .makedirs (os .path .join (output_dir , "labels" , "val" ), exist_ok = True )
26191
27192 # List all label files
28- files = os .listdir (labels_path )
193+ files = [ f for f in os .listdir (labels_path ) if f . endswith ( '.txt' )]
29194
30195 # Split data into training and validation indices
31- train_idx , val_idx = tts (np .arange (0 , len (files ), 1 ), shuffle = True )
196+ train_idx , val_idx = tts (np .arange (0 , len (files ), 1 ), test_size = test_size , shuffle = True , random_state = 42 )
32197
33198 # Iterate through files and copy them into train/val directories
34199 for idx , file in enumerate (files ):
35- file_name = file [:- 4 ] # Remove file extension
200+ file_name = os .path .splitext (file )[0 ]
201+
202+ # Find the image file (try common extensions)
203+ image_file = None
204+ for ext in ['.png' , '.jpg' , '.jpeg' , '.tif' , '.tiff' , '.mrc' ]:
205+ potential_image = os .path .join (images_path , file_name + ext )
206+ if os .path .exists (potential_image ):
207+ image_file = file_name + ext
208+ break
209+
210+ if image_file is None :
211+ print (f"Warning: Image file not found for { file_name } . Skipping." )
212+ continue
213+
36214 if idx in train_idx :
37215 # Copy training images and labels
38216 shutil .copy (
39- os .path .join (images_path , file_name + ".png" ),
40- os .path .join (output_dir , "images" , "train" , file_name + ".png" )
217+ os .path .join (images_path , image_file ),
218+ os .path .join (output_dir , "images" , "train" , image_file )
41219 )
42220 shutil .copy (
43- os .path .join (labels_path , file_name + ".txt" ),
44- os .path .join (output_dir , "labels" , "train" , file_name + ".txt" )
221+ os .path .join (labels_path , file ),
222+ os .path .join (output_dir , "labels" , "train" , file )
45223 )
46224 elif idx in val_idx :
47225 # Copy validation images and labels
48226 shutil .copy (
49- os .path .join (images_path , file_name + ".png" ),
50- os .path .join (output_dir , "images" , "val" , file_name + ".png" )
227+ os .path .join (images_path , image_file ),
228+ os .path .join (output_dir , "images" , "val" , image_file )
51229 )
52230 shutil .copy (
53- os .path .join (labels_path , file_name + ".txt" ),
54- os .path .join (output_dir , "labels" , "val" , file_name + ".txt" )
231+ os .path .join (labels_path , file ),
232+ os .path .join (output_dir , "labels" , "val" , file )
55233 )
56234
57235 # Create val.txt file listing validation image paths
58236 with open (os .path .join (output_dir , "val.txt" ), "w" ) as f :
59- for file in os .listdir (os .path .join (output_dir , "images" , "val" )):
237+ for file in sorted ( os .listdir (os .path .join (output_dir , "images" , "val" ) )):
60238 f .write (str (os .path .join (output_dir , "images" , "val" , file )) + "\n " )
61239
62240 # Create train.txt file listing training image paths
63241 with open (os .path .join (output_dir , "train.txt" ), "w" ) as f :
64- for file in os .listdir (os .path .join (output_dir , "images" , "train" )):
242+ for file in sorted ( os .listdir (os .path .join (output_dir , "images" , "train" ) )):
65243 f .write (str (os .path .join (output_dir , "images" , "train" , file )) + "\n " )
66244
67245 # Create cryo_training.yaml file with dataset configuration
@@ -76,22 +254,92 @@ def main(labels_path: str, images_path: str, output_dir: str):
76254
77255 with open (os .path .join (output_dir , "cryo_training.yaml" ), "w" ) as f :
78256 f .write (to_write )
257+
258+ train_count = len (os .listdir (os .path .join (output_dir , "images" , "train" )))
259+ val_count = len (os .listdir (os .path .join (output_dir , "images" , "val" )))
260+ print (f"\n Dataset split complete!" )
261+ print (f"Training samples: { train_count } " )
262+ print (f"Validation samples: { val_count } " )
263+ print (f"Configuration saved to: { os .path .join (output_dir , 'cryo_training.yaml' )} " )
79264
80- def parse_args () -> argparse .Namespace :
81- """
82- Parses command-line arguments for the dataset splitting script.
83265
84- Returns:
85- argparse.Namespace: Parsed arguments containing paths for labels, images, and output.
266+ def main (star_path : str , images_path : str , output_dir : str , class_id : int = 0 ,
267+ test_size : float = 0.25 , split_only : bool = False ):
268+ """
269+ Main function to convert STAR file to YOLO format and split into train/val sets
270+
271+ Args:
272+ star_path: Path to input STAR file (or labels directory if split_only=True)
273+ images_path: Path to directory containing micrograph images
274+ output_dir: Path to output directory
275+ class_id: Class ID to assign to all particles (default: 0)
276+ test_size: Proportion of dataset to use for validation (default: 0.25)
277+ split_only: If True, skip conversion and only split existing labels
86278 """
87- parser = argparse .ArgumentParser (description = "Create training data split from images and labels." )
88- parser .add_argument ("--labels" , required = True , help = "Path to the labels directory" )
89- parser .add_argument ("--images" , required = True , help = "Path to the images directory" )
90- parser .add_argument ("--output" , required = True , help = "Path to the output directory" )
279+ if split_only :
280+ # Skip conversion, just split existing labels
281+ print ("Splitting existing labels into train/val sets..." )
282+ split_train_val (star_path , images_path , output_dir , test_size )
283+ else :
284+ # Create temporary directory for converted labels
285+ temp_labels_dir = os .path .join (output_dir , "temp_labels" )
286+
287+ # Convert STAR to YOLO
288+ print ("=" * 60 )
289+ print ("Step 1: Converting STAR file to YOLO format" )
290+ print ("=" * 60 )
291+ convert_star_to_yolo (star_path , images_path , temp_labels_dir , class_id )
292+
293+ # Split into train/val
294+ print ("\n " + "=" * 60 )
295+ print ("Step 2: Splitting data into train/val sets" )
296+ print ("=" * 60 )
297+ split_train_val (temp_labels_dir , images_path , output_dir , test_size )
298+
299+ # Clean up temporary labels directory
300+ shutil .rmtree (temp_labels_dir )
301+ print (f"\n All done! Training data ready in { output_dir } " )
91302
303+
304+ def parse_args () -> argparse .Namespace :
305+ parser = argparse .ArgumentParser (
306+ description = "Convert STAR file to YOLO format and split into train/val sets"
307+ )
308+ parser .add_argument (
309+ "--star" ,
310+ required = True ,
311+ help = "Path to input STAR file (or labels directory if using --split-only)"
312+ )
313+ parser .add_argument (
314+ "--images" ,
315+ required = True ,
316+ help = "Path to directory containing micrograph images"
317+ )
318+ parser .add_argument (
319+ "--output" ,
320+ required = True ,
321+ help = "Path to output directory for organized train/val data"
322+ )
323+ parser .add_argument (
324+ "--class-id" ,
325+ type = int ,
326+ default = 0 ,
327+ help = "Class ID to assign to all particles (default: 0)"
328+ )
329+ parser .add_argument (
330+ "--test-size" ,
331+ type = float ,
332+ default = 0.25 ,
333+ help = "Proportion of dataset to use for validation (default: 0.25)"
334+ )
335+ parser .add_argument (
336+ "--split-only" ,
337+ action = "store_true" ,
338+ help = "Skip STAR conversion and only split existing labels (use --star to specify labels directory)"
339+ )
92340 return parser .parse_args ()
93341
342+
94343if __name__ == "__main__" :
95- # Parse command-line arguments and execute the main function
96344 args = parse_args ()
97- main (args .labels , args .images , args .output )
345+ main (args .star , args .images , args .output , args . class_id , args . test_size , args . split_only )
0 commit comments