Coverage for collectra/converters/yolo.py: 81.73%

104 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 09:56 +0000

1import shutil 

2from pathlib import Path 

3 

4from pydantic import BaseModel, Field 

5 

6from ..commons.files import CollectraFile 

7from .base import Converter 

8 

9 

10class YOLOConfig(BaseModel): 

11 """ 

12 Configuration for YOLO dataset conversion. 

13 This class defines the structure of the YOLO configuration file, which includes: 

14 - Paths to the training and validation datasets, 

15 - The number of classes 

16 - The list of class names. 

17 """ 

18 

19 train: str = Field(..., description="Path to the training dataset") 

20 val: str = Field(..., description="Path to the validation dataset") 

21 nc: int = Field(..., description="Number of classes") 

22 names: list[str] = Field(..., description="List of class names") 

23 

24 @classmethod 

25 def from_yaml(cls, yaml_path: Path) -> "YOLOConfig": 

26 """Load YOLO configuration from a YAML file. 

27 

28 Args: 

29 yaml_path (Path): Path to the YAML configuration file. 

30 

31 Returns: 

32 YOLOConfig: The loaded YOLO configuration. 

33 """ 

34 import yaml 

35 

36 with open(yaml_path, "r") as f: 

37 data = yaml.safe_load(f) 

38 return cls(**data) 

39 

40 

41class YOLOConverter(Converter): 

42 """ 

43 Converter for YOLO dataset format to Collectra format. 

44 This converter reads the YOLO configuration file, processes the training and validation datasets, and converts the annotations to Collectra format. 

45 The converted files are saved in the specified output directory. 

46 """ 

47 

48 input: Path 

49 output: Path 

50 root_label: str 

51 ext: str 

52 force: bool 

53 

54 def __init__( 

55 self, 

56 input: str | Path, 

57 root_label: str, 

58 ext: str, 

59 output: str | Path, 

60 force: bool = False, 

61 ): 

62 """Initialize the YOLOConverter. Process the input configuration file or folder to extract necessary information for conversion. 

63 

64 Args: 

65 input (str | Path): Path to the YOLO configuration file or folder. 

66 root_label (str): Root label for converted data. 

67 ext (str): File extension for converted collectra files. 

68 output (str | Path): Output folder for converted files. 

69 force (bool, optional): Whether to force overwrite existing files. Defaults to False. 

70 

71 """ 

72 self.input = Path(input) 

73 self.root_label = root_label 

74 self.ext = ext 

75 self.output = Path(output) 

76 self.force = force 

77 self._get_config() 

78 

79 def _get_config(self) -> YOLOConfig: 

80 """Get the YOLO configuration from the input file or directory. 

81 

82 Returns: 

83 YOLOConfig: The YOLO configuration. 

84 

85 """ 

86 if not (self.input.is_file() or self.input.is_dir()): 

87 raise ValueError( 

88 f"Input path {self.input} is neither a file nor a directory." 

89 ) 

90 if self.input.is_file(): 

91 if self.input.suffix != ".yaml": 

92 raise ValueError( 

93 f"Expected a YAML config file, but got {self.input.suffix}" 

94 ) 

95 if self.input.is_dir(): 

96 config_files = list(self.input.glob("*.yaml")) 

97 if not config_files: 

98 raise ValueError( 

99 f"No YAML config files found in directory {self.input}" 

100 ) 

101 if len(config_files) > 1: 

102 raise ValueError( 

103 f"Multiple YAML config files found in directory {self.input}. Please ensure there is only one config file." 

104 ) 

105 self.input = config_files[0] 

106 return YOLOConfig.from_yaml(self.input) 

107 

108 def _convert_file( 

109 self, file: Path, txt_file: Path, partition: str | None = None 

110 ) -> None: 

111 collectra_file = CollectraFile.from_file( 

112 file=file, 

113 label=self.root_label, 

114 ext=self.ext, 

115 output=self.output, 

116 force=self.force, 

117 partition=partition, 

118 ) 

119 """ Read the YOLO annotation file and convert it to Collectra format.  

120 The YOLO annotation file contains lines with the format: 

121 <class_id> <x_center> <y_center> <width_relative> <height_relative> 

122 Save the converted data in the Collectra file. 

123 

124 Args:  

125 file (Path): Path to the image file. 

126 txt_file (Path): Path to the YOLO annotation file. 

127 

128 """ 

129 with open(txt_file, "r") as f: 

130 crops = [line.strip().split(" ") for line in f if line.strip()] 

131 

132 import uuid 

133 

134 for crop in crops: 

135 class_id, x_center, y_center, width_relative, height_relative = crop 

136 class_id = int(class_id) 

137 class_name = self._get_config().names[class_id] 

138 if class_name not in collectra_file.data: 

139 collectra_file.data[class_name] = [] 

140 if not isinstance(collectra_file.data[class_name], list): 

141 collectra_file.data[class_name] = [collectra_file.data[class_name]] 

142 collectra_file.data[class_name].append( 

143 { 

144 "id": f"{class_name}-{uuid.uuid4().hex[:8]}", 

145 "data": file.name, 

146 "type": "collectra.ImageCrop", 

147 "x_center": float(x_center), 

148 "y_center": float(y_center), 

149 "parents": self.root_label, 

150 "width_relative": float(width_relative), 

151 "height_relative": float(height_relative), 

152 } 

153 ) 

154 

155 collectra_file.save() 

156 

157 def _process_files(self, input_of_files: Path, partition): 

158 """Process the files listed in the input file. 

159 The input file contains paths to image files, one per line. 

160 For each image file, find the corresponding YOLO annotation file and convert it to Collectra format. 

161 

162 Args: 

163 input_of_files (Path): Path to the file containing paths to image files. 

164 

165 Raises: 

166 ValueError: If any of the image files or corresponding annotation files do not exist. 

167 RuntimeError: If there is an error during file processing, the output directory will be cleaned 

168 

169 """ 

170 files: list[Path] = [] 

171 text_files: list[Path] = [] 

172 with open(input_of_files, "r") as f: 

173 for line in f: 

174 if not line.strip(): 

175 continue 

176 file_path = ( 

177 Path(line.strip()) 

178 if str(line.strip()).startswith("/") 

179 else input_of_files.parent / line.strip() 

180 ) 

181 txt_path = Path(str(file_path).replace("images", "labels")).with_suffix( 

182 ".txt" 

183 ) 

184 if not file_path.exists() or not txt_path.exists(): 

185 raise ValueError(f"File {file_path} or {txt_path} does not exist.") 

186 files.append(file_path) 

187 text_files.append(txt_path) 

188 try: 

189 self.output.mkdir(parents=True, exist_ok=True) 

190 for file, txt_file in zip(files, text_files): 

191 self._convert_file(file, txt_file, partition=partition) 

192 except Exception as e: 

193 shutil.rmtree(self.output, ignore_errors=True) 

194 raise RuntimeError(f"Failed to process files: {e}") 

195 

196 def convert(self) -> None: 

197 """Convert the YOLO dataset to Collectra format. Process both training and validation datasets as specified in the YOLO configuration.""" 

198 config = self._get_config() 

199 self._process_files( 

200 self.input.parent / config.train, partition=str(Path(config.train).stem) 

201 ) 

202 self._process_files( 

203 self.input.parent / config.val, partition=str(Path(config.val).stem) 

204 ) 

205 

206 

207class YOLOClassifierCSV: 

208 """ 

209 Converter for YOLO classifier dataset format to Collectra format. 

210 This converter extends the YOLOConverter to handle classification datasets in YOLO format. 

211 It processes the training and validation datasets, converts the annotations to Collectra format, and saves the converted files in the specified output directory. 

212 """ 

213 

214 def __init__( 

215 self, 

216 input: str | Path, 

217 root_label: str, 

218 ext: str, 

219 output: str | Path, 

220 force: bool = False, 

221 ): 

222 """Initialize the YOLOConverter. Process the input configuration file or folder to extract necessary information for conversion. 

223 

224 Args: 

225 input (str | Path): Path to the YOLO configuration file or folder. 

226 root_label (str): Root label for converted data. 

227 ext (str): File extension for converted collectra files. 

228 output (str | Path): Output folder for converted files. 

229 force (bool, optional): Whether to force overwrite existing files. Defaults to False. 

230 

231 """ 

232 self.input = Path(input) 

233 self.root_label = root_label 

234 self.ext = ext 

235 self.output = Path(output) 

236 self.force = force 

237 

238 def convert(self) -> None: 

239 import pandas as pd 

240 

241 config_file = pd.read_csv(self.input) 

242 for file_data in config_file.itertuples(): 

243 source_file = self.input.parent / str(file_data.path) 

244 label_data = str(file_data.tag) 

245 validation = "validation" if file_data.validation else "train" 

246 print( 

247 f"Processing file {source_file} with label {label_data} for partition {validation}" 

248 ) 

249 self._convert_file(source_file, label_data, partition=validation) 

250 

251 def _convert_file( 

252 self, file: Path, label_data: str, partition: str | None = None 

253 ) -> None: 

254 import uuid 

255 

256 collectra_file = CollectraFile.from_file( 

257 file=file, 

258 label=self.root_label, 

259 ext=self.ext, 

260 output=self.output, 

261 force=self.force, 

262 partition=partition, 

263 ) 

264 collectra_file.data[f"{self.root_label}_writing_type"] = { 

265 "id": f"{self.root_label}_writing_type-{uuid.uuid4().hex[:8]}", 

266 "type": "collectra.Text", 

267 "parents": collectra_file.data[self.root_label]["id"], 

268 "data": label_data, 

269 } 

270 collectra_file.save()