Coverage for src/pygnd/io.py: 97%

241 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-03 23:45 +0000

1import os 

2from pathlib import Path 

3 

4from PIL import Image 

5import h5py 

6import numpy as np 

7 

8 

9# Define Dream3d data types 

10_DREAM3D_DTYPES = { 

11 np.uint8: "DataArray<uint8_t> ", 

12 np.int8: "DataArray<int8_t> ", 

13 np.uint16: "DataArray<uint16_t> ", 

14 np.int16: "DataArray<int16_t> ", 

15 np.uint32: "DataArray<uint32_t> ", 

16 np.int32: "DataArray<int32_t> ", 

17 np.uint64: "DataArray<uint64_t> ", 

18 np.int64: "DataArray<int64_t> ", 

19 np.float32: "DataArray<float> ", 

20 np.float64: "DataArray<double> ", 

21 bool: "DataArray<bool> ", 

22} 

23_XDMF_DTYPE_FORMATS = { # (NumberType, Precision) 

24 np.uint8: ("UChar", "1"), 

25 np.int8: ("Char", "1"), 

26 np.uint16: ("UInt", "2"), 

27 np.int16: ("Int", "2"), 

28 np.uint32: ("UInt", "4"), 

29 np.int32: ("Int", "4"), 

30 np.uint64: ("UInt", "8"), 

31 np.int64: ("Int", "8"), 

32 np.float32: ("Float", "4"), 

33 np.float64: ("Float", "8"), 

34 bool: ("uchar", "1"), 

35} 

36 

37 

38def read_ang( 

39 path: str | Path, ids_path: str | Path | None = None 

40) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

41 """ 

42 Reads an .ang file into a numpy array 

43 If ids_path is provided, reads the Feature IDs from that file as well. 

44 If ids_path is None, all Feature IDs are set to 1. 

45 The ids_path should be a path to a grain data file generated by OIM Analysis. 

46 If not generated by OIM, the Feature IDs should be in the 9th column (index 8) of the file and be a integer grain ID for each point of the .ang file. 

47 

48 Parameters: 

49 path (str): Path to the .ang file. 

50 ids_path (Optional[str]): Path to the file containing Feature IDs. 

51 Returns: 

52 Tuple[np.ndarray, np.ndarray, np.ndarray]: Euler angles, Feature IDs, spacing. 

53 """ 

54 num_header_lines = 0 

55 col_names = None 

56 ncols = nrows = res = None 

57 with open(path, "r", encoding="utf-8") as f: 

58 for line in f: 58 ↛ 71line 58 didn't jump to line 71

59 if line[0] == "#": 

60 num_header_lines += 1 

61 if "NCOLS_ODD" in line: 

62 ncols = int(line.split(": ")[1].strip()) 

63 elif "NROWS" in line: 

64 nrows = int(line.split(": ")[1].strip()) 

65 elif "COLUMN_HEADERS" in line: 

66 col_names = line.split(": ")[1].strip().split(", ") 

67 elif "XSTEP" in line: 

68 res = float(line.split(": ")[1].strip()) 

69 else: 

70 break 

71 if ncols is None or nrows is None or res is None: 

72 raise ValueError( 

73 "The .ang header is missing one of the required NCOLS_ODD, NROWS, or XSTEP fields." 

74 ) 

75 raw_data = np.genfromtxt(path, skip_header=num_header_lines) 

76 n_entries = raw_data.shape[-1] 

77 if col_names is None: 

78 default_names = ["phi1", "PHI", "phi2", "x", "y", "IQ", "CI", "Phase index"] 

79 col_names = default_names[:n_entries] + [ 

80 f"col_{i}" for i in range(len(default_names), n_entries) 

81 ] 

82 if raw_data.shape[0] != ncols * nrows: 

83 raise ValueError( 

84 f"The number of data points ({raw_data.shape[0]}) does not match the expected grid ({nrows} rows, {ncols} cols, {ncols * nrows} total points). " 

85 ) 

86 data = raw_data.reshape((nrows, ncols, n_entries)) 

87 

88 out = {col_names[i]: data[:, :, i] for i in range(n_entries)} 

89 eulerangles = np.array([out["phi1"], out["PHI"], out["phi2"]]).T.astype(float) 

90 eulerangles = eulerangles.reshape(1, *eulerangles.shape).transpose(0, 2, 1, 3) 

91 if ids_path is not None: 

92 grain_data = np.genfromtxt(ids_path, dtype=float, comments="#") 

93 ids = grain_data[:, 8].reshape(eulerangles.shape[:-1]).astype(int) 

94 else: 

95 ids = np.ones(eulerangles.shape[:-1], dtype=int) 

96 spacing = np.array([res, res, res]) * 1e-6 # Convert from microns to meters 

97 return eulerangles, ids, spacing 

98 

99 

100def read_dream3d( 

101 path: str | Path, 

102 ids_name: str = "FeatureIds", 

103 euler_name: str = "EulerAngles", 

104 spacing_units: str = "microns", 

105) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

106 """ 

107 Reads Euler angles, Feature IDs, and spacing from a DREAM3D file. 

108 

109 Parameters: 

110 path (str): Path to the DREAM3D file. 

111 ids_name (str): Name of the DataArray containing the Feature IDs. 

112 euler_name (str): Name of the DataArray containing the Euler angles. 

113 spacing_units (str): Units of the spacing in the DREAM3D file. Options are 

114 'nm', 'nanometer', 'nanometers', 'um', 'µm', 'micron', 'microns', 'micrometer', 

115 'micrometers', 'mm', 'millimeter', 'millimeters', 'm', 'meter', 'meters'. 

116 Returns: 

117 Tuple[np.ndarray, np.ndarray, np.ndarray]: Euler angles, Feature IDs, spacing. 

118 """ 

119 if not os.path.exists(path): 

120 raise FileNotFoundError(f"DREAM3D file not found at path: {Path(path).absolute()}") 

121 

122 ids = extract_data_from_h5(path, ids_name) 

123 if ids is None: 

124 raise KeyError( 

125 f"Could not find a data array with the name '{ids_name}' in the dream3d file." 

126 ) 

127 ids = ids[..., 0] if ids.ndim == 4 and ids.shape[-1] == 1 else ids 

128 

129 eulerangles = extract_data_from_h5(path, euler_name) 

130 if eulerangles is None: 

131 raise KeyError( 

132 f"Could not find a data array with the name '{euler_name}' in the dream3d file." 

133 ) 

134 

135 spacing = read_dream3d_spacing(path, spacing_units=spacing_units) 

136 

137 return eulerangles, ids, spacing 

138 

139 

140def read_dream3d_spacing(path: str, spacing_units: str = "microns") -> np.ndarray: 

141 """ 

142 Reads the spacing information from a DREAM3D file and converts it to meters. 

143 

144 Parameters: 

145 path (str): Path to the DREAM3D file. 

146 spacing_units (str): Units of the spacing in the DREAM3D file. Options are 

147 'nm', 'nanometer', 'nanometers', 'um', 'µm', 'micron', 'microns', 'micrometer', 

148 'micrometers', 'mm', 'millimeter', 'millimeters', 'm', 'meter', 'meters'. 

149 Returns: 

150 np.ndarray: Spacing in meters. 

151 """ 

152 if not os.path.exists(path): 

153 raise FileNotFoundError(f"DREAM3D file not found at path: {path}") 

154 

155 spacing = extract_data_from_h5(path, "SPACING") 

156 

157 # If the spacing path isn't found, it is likely a dream3dnx file and spacing is an attribute 

158 if spacing is None: 

159 spacing = extract_attribute_from_h5(path, "_SPACING") 

160 if spacing is None: 160 ↛ 166line 160 didn't jump to line 166 because the condition on line 160 was always true

161 raise ValueError( 

162 "Could not find spacing information in the dream3d file. Attempted to find a 'SPACING' dataset (DREAM3D format) and a '_SPACING' attribute (DREAM3DNX format)." 

163 ) 

164 

165 # Convert spacing to meters 

166 if spacing_units == "nm" or spacing_units == "nanometer" or spacing_units == "nanometers": 

167 spacing *= 1e-9 

168 elif ( 

169 spacing_units == "um" 

170 or spacing_units == "micron" 

171 or spacing_units == "microns" 

172 or spacing_units == "micrometer" 

173 or spacing_units == "micrometers" 

174 or spacing_units == "µm" 

175 ): 

176 spacing *= 1e-6 

177 elif spacing_units == "mm" or spacing_units == "millimeter" or spacing_units == "millimeters": 

178 spacing *= 1e-3 

179 elif spacing_units == "m" or spacing_units == "meter" or spacing_units == "meters": 

180 pass 

181 else: 

182 raise ValueError( 

183 "units must be one of 'nm', 'nanometer', 'um', 'µm', 'micron', 'microns', 'micrometer', 'micrometers', 'mm', 'millimeter', 'millimeters', 'm', or 'meters'." 

184 ) 

185 

186 return spacing 

187 

188 

189def add_dataset_to_h5(h5group: h5py.Group, name: str, data: np.ndarray) -> h5py.Dataset: 

190 """ 

191 Adds a new dataset to an existing HDF5 group. Designed to be used with DREAM3D files. 

192 

193 Parameters: 

194 h5group (h5py.Group): The HDF5 group to add the dataset to. 

195 name (str): The name of the new dataset. 

196 data (np.ndarray): The data to be stored in the dataset. 

197 Returns: 

198 h5py.Dataset: The created dataset. 

199 """ 

200 # Check that h5group is indeed an h5py.Group 

201 if not isinstance(h5group, h5py.Group): 

202 raise TypeError("h5group must be an instance of h5py.Group") 

203 

204 # Check that the data is the same shape as other datasets in the group 

205 for key in h5group.keys(): 

206 if isinstance(h5group[key], h5py.Dataset): 206 ↛ 205line 206 didn't jump to line 205 because the condition on line 206 was always true

207 if h5group[key].shape[:-1] != data.shape[:-1]: 

208 raise ValueError( 

209 f"Data shape {data.shape} does not match existing dataset shape {h5group[key].shape} in the group." 

210 ) 

211 break 

212 

213 # Check to see if the dataset already exists, if so just overwrite it 

214 if name in h5group: 

215 print(f"Dataset '{name}' already exists in HDF5 group. Overwriting.") 

216 h5group[name][...] = data 

217 return h5group[name] 

218 

219 dtype = data.dtype.type 

220 if dtype not in _DREAM3D_DTYPES: 

221 raise TypeError(f"Unsupported data type for DREAM3D: {dtype}") 

222 dset = h5group.create_dataset(name, data=data, dtype=dtype) 

223 dset.attrs["ComponentDimensions"] = np.uint64([data.shape[-1]]) 

224 dset.attrs["Tuple Axis Dimensions"] = np.bytes_( 

225 f"x={str(data.shape[2])},y={str(data.shape[1])},z={str(data.shape[0])} " 

226 ) 

227 dset.attrs["DataArrayVersion"] = np.int32([2]) 

228 dset.attrs["ObjectType"] = np.bytes_(_DREAM3D_DTYPES[dtype]) 

229 dset.attrs["TupleDimensions"] = np.uint64(np.squeeze(data.shape[:-1][::-1])) 

230 print(f"Added dataset '{name}' to HDF5 group.") 

231 

232 return dset 

233 

234 

235def add_dataset_to_xdmf(xdmf_path: str | Path, dataset_name: str, data_array: np.ndarray) -> None: 

236 """ 

237 Adds a new dataset to an existing XDMF file. Designed to be used with DREAM3D files. 

238 

239 Parameters: 

240 xdmf_path (str): Path to the XDMF file. 

241 dataset_name (str): The name of the new dataset. 

242 data_array (np.ndarray): The data array to be referenced in the XDMF file 

243 Returns: 

244 None 

245 """ 

246 # Read the existing XDMF file 

247 with open(xdmf_path, "r") as file: 

248 xdmf_content = file.readlines() 

249 

250 # Break the xdmf content into lines for easier manipulation 

251 xdmf_content = [line.replace("\n", "") for line in xdmf_content] 

252 

253 # Make sure the shape of the data_array is compatible 

254 if data_array.ndim == 3: 

255 data_array = data_array.reshape(data_array.shape + (1,)) 

256 elif data_array.ndim < 3: 

257 raise ValueError("data_array must be at least 3-dimensional") 

258 elif data_array.ndim > 4: 258 ↛ 260line 258 didn't jump to line 260 because the condition on line 258 was always true

259 raise ValueError("data_array must be at most 4-dimensional") 

260 dimensions = ( 

261 xdmf_content[["<Topology" in line for line in xdmf_content].index(True)] 

262 .split("Dimensions=")[1] 

263 .split('"')[1] 

264 .strip() 

265 ) 

266 data_array_dims = " ".join(map(str, np.array(data_array.shape[0:3]) + 1)) 

267 if dimensions != data_array_dims: 

268 raise ValueError("data_array dimensions are not compatible with XDMF Topology dimensions") 

269 

270 # Make sure an entry with the same name does not already exist, if it does then just return 

271 for line in xdmf_content: 

272 if f'Attribute Name="{dataset_name}"' in line: 

273 print(f"Dataset '{dataset_name}' already exists in XDMF file. Skipping addition.") 

274 return 

275 

276 # Determine the insertion point (put the new entry at the end of the Grid section) 

277 insertion_index = ["</Grid>" in line for line in xdmf_content].index(True) 

278 

279 # Gather relevant data for the new dataset 

280 data_type, precision = _XDMF_DTYPE_FORMATS[data_array.dtype.type] 

281 dimensions = " ".join(map(str, data_array.shape)) 

282 attribute_type = ( 

283 "Scalar" 

284 if (data_array.ndim == 3) or ((data_array.ndim == 4) and (data_array.shape[-1] == 1)) 

285 else "Vector" 

286 ) 

287 file_path = ( 

288 xdmf_content[[".dream3d:/" in line for line in xdmf_content].index(True)].strip().split("/") 

289 ) 

290 file_path[-1] = dataset_name 

291 file_path = "/".join(file_path) 

292 

293 # Create the new DataItem entry 

294 xdmf_content.insert( 

295 insertion_index, 

296 f' <Attribute Name="{dataset_name}" AttributeType="{attribute_type}" Center="Cell">', 

297 ) 

298 xdmf_content.insert( 

299 insertion_index + 1, 

300 f' <DataItem Format="HDF" Dimensions="{dimensions}" NumberType="{data_type}" Precision="{precision}" >', 

301 ) 

302 xdmf_content.insert( 

303 insertion_index + 2, 

304 f" {file_path}", 

305 ) 

306 xdmf_content.insert(insertion_index + 3, " </DataItem>") 

307 xdmf_content.insert(insertion_index + 4, " </Attribute>") 

308 

309 # Write the modified content back to the XDMF file 

310 with open(xdmf_path, "w") as file: 

311 for line in xdmf_content: 

312 file.write(line + "\n") 

313 

314 print(f"Added dataset '{dataset_name}' to XDMF file.") 

315 return 

316 

317 

318def save_to_dream3d(path: str | Path, ids_name: str, gnd_data: dict, fdm_data: np.ndarray) -> bool: 

319 """ 

320 Saves GND and FDM data to a DREAM3D file. 

321 

322 Parameters: 

323 path (str): Path to the DREAM3D file. 

324 ids_name (str): Name of the DataArray containing the Feature IDs. 

325 gnd_data (dict): Dictionary containing GND data arrays keyed by minimization method. 

326 fdm_data (np.ndarray): FDM data array. 

327 Returns: 

328 bool: True if successful. 

329 """ 

330 # Get the path to the ids array 

331 ids_path = extract_path_from_h5(path, ids_name) 

332 if ids_path is None: 

333 raise KeyError( 

334 f"Could not find Feature IDs data array with name '{ids_name}' in DREAM3D file. This is required to determine the cell data group." 

335 ) 

336 

337 # Use the ids path to find the cell data group and create paths for new data 

338 cell_data_path = "/".join(ids_path.split("/")[:-1]) 

339 xdmf_path = str(Path(path).with_suffix(".xdmf")) 

340 modify_xdmf = os.path.exists(xdmf_path) 

341 if not modify_xdmf: 341 ↛ 347line 341 didn't jump to line 347 because the condition on line 341 was always true

342 print( 

343 "WARNING: Could not find associated XDMF file. New datasets will only be added to the DREAM3D file. Please run DREAM3D to generate an updated XDMF file." 

344 ) 

345 

346 # Prep the data 

347 data_shape = fdm_data.shape[1:] + (1,) 

348 for m in gnd_data: 

349 gnd_data[m] = gnd_data[m].sum(axis=0).reshape(data_shape).copy() 

350 fdm_avg = fdm_data.mean(axis=0).reshape(data_shape) 

351 fdm_max = fdm_data.max(axis=0).reshape(data_shape) 

352 

353 # Now open the new file and write the new data 

354 with h5py.File(path, "r+") as h5: 

355 cell_data = h5[cell_data_path] 

356 

357 # Add GND and FDM datasets 

358 for m in gnd_data: 

359 add_dataset_to_h5(cell_data, f"GND_{m}", gnd_data[m]) 

360 add_dataset_to_h5(cell_data, "FDM_avg", fdm_avg) 

361 add_dataset_to_h5(cell_data, "FDM_max", fdm_max) 

362 

363 # Update the XDMF file if it exists 

364 if modify_xdmf: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true

365 for m in gnd_data: 

366 add_dataset_to_xdmf(xdmf_path, f"GND_{m}", gnd_data[m]) 

367 add_dataset_to_xdmf(xdmf_path, "FDM_avg", fdm_avg) 

368 add_dataset_to_xdmf(xdmf_path, "FDM_max", fdm_max) 

369 

370 return True 

371 

372 

373def extract_path_from_h5(h5_file_path: str, target_name: str) -> str: 

374 """ 

375 Extracts the path to a dataset from an HDF5 file given its name. 

376 

377 Parameters: 

378 h5_file_path (str): Path to the HDF5 file. 

379 target_name (str): Name of the target dataset to extract. 

380 Returns: 

381 str: The path to the dataset, or None if not found. 

382 """ 

383 with h5py.File(h5_file_path, "r") as h5: 

384 

385 def recursive_search(name, obj): 

386 if isinstance(obj, h5py.Dataset): 

387 if name == target_name or name.endswith("/" + target_name): 

388 return name 

389 else: 

390 return None 

391 for key, item in obj.items(): 

392 result = recursive_search(f"{name}/{key}", item) 

393 if result: 

394 return result 

395 return None 

396 

397 path = recursive_search("", h5) 

398 return path 

399 

400 

401def extract_attribute_from_h5(h5_file_path: str, attribute_name: str) -> any: 

402 """ 

403 Extracts an attribute from an HDF5 file given its name. 

404 

405 Parameters: 

406 h5_file_path (str): Path to the HDF5 file. 

407 attribute_name (str): Name of the target attribute to extract. 

408 Returns: 

409 The value of the attribute, or None if not found. 

410 """ 

411 if not os.path.exists(h5_file_path): 

412 raise FileNotFoundError(f"HDF5 file not found at path: {h5_file_path}") 

413 

414 with h5py.File(h5_file_path, "r") as h5: 

415 

416 def recursive_search(name, obj): 

417 print("Visiting:", name) 

418 if isinstance(obj, h5py.Group): 

419 print(" - attrs:", obj.attrs.keys()) 

420 if attribute_name in obj.attrs: 

421 return obj.attrs[attribute_name] 

422 else: 

423 return None 

424 print(" - Did not find attribute in group") 

425 for key, item in obj.items(): 

426 result = recursive_search(f"{name}/{key}", item) 

427 print(" - Result from", key, ":", result) 

428 if result is not None: 

429 return result 

430 return None 

431 

432 attribute_value = recursive_search("", h5) 

433 return attribute_value 

434 

435 

436def extract_data_from_h5(h5_file_path: str, target_name: str) -> np.ndarray: 

437 """ 

438 Extracts a data array from an HDF5 file given its name. 

439 

440 Parameters: 

441 h5_file_path (str): Path to the HDF5 file. 

442 target_name (str): Name of the target data array to extract. 

443 Returns: 

444 np.ndarray: The extracted data array, or None if not found. 

445 """ 

446 if not os.path.exists(h5_file_path): 

447 raise FileNotFoundError(f"HDF5 file not found at path: {h5_file_path}") 

448 

449 with h5py.File(h5_file_path, "r") as h5: 

450 

451 def recursive_search(name, obj): 

452 if isinstance(obj, h5py.Dataset): 

453 if name == target_name or name.endswith("/" + target_name): 

454 return obj[...] 

455 else: 

456 return None 

457 for key, item in obj.items(): 

458 result = recursive_search(f"{name}/{key}", item) 

459 if result is not None: 

460 return result 

461 return None 

462 

463 data_array = recursive_search("", h5) 

464 return data_array 

465 

466 

467def save_npz(gnd_data: dict[np.ndarray], fdm_data: np.ndarray, folder: str | Path) -> None: 

468 """ 

469 Saves GND and FDM data arrays to a .npz file. 

470 

471 Parameters: 

472 gnd_data (dict[np.ndarray]): Dictionary containing GND data arrays keyed by minimization method. 

473 fdm_data (np.ndarray): FDM data array. 

474 folder (str): Folder to save the .npz file. 

475 Returns: 

476 None 

477 """ 

478 for m in gnd_data: 

479 np.save(Path(folder) / f"gnd_{m}.npy", gnd_data[m]) 

480 np.save(Path(folder) / "fdm.npy", fdm_data) 

481 

482 

483def remove_npz(folder: str | Path) -> None: 

484 """ 

485 Removes GND and FDM .npy files from the specified folder. 

486 

487 Parameters: 

488 gnd_data (dict[np.ndarray]): Dictionary containing GND data arrays keyed by minimization method. 

489 fdm_data (np.ndarray): FDM data array. 

490 folder (str): Folder to remove the .npy files from. 

491 Returns: 

492 None 

493 """ 

494 if (Path(folder) / "fdm.npy").exists(): 

495 Path(folder).joinpath("fdm.npy").unlink() 

496 if (Path(folder) / "gnd_l1.npy").exists(): 

497 Path(folder).joinpath("gnd_l1.npy").unlink() 

498 if (Path(folder) / "gnd_l2.npy").exists(): 

499 Path(folder).joinpath("gnd_l2.npy").unlink() 

500 

501 

502def generate_images(gnd_data: dict[np.ndarray], fdm_data: np.ndarray, folder: str | Path) -> None: 

503 """ 

504 Generates and saves images of GND and FDM data arrays as .npy files. 

505 

506 Parameters: 

507 gnd_data (dict[np.ndarray]): Dictionary containing GND data arrays keyed by minimization method. 

508 fdm_data (np.ndarray): FDM data array. 

509 folder (str): Folder to save the .npy files. 

510 Returns: 

511 None 

512 """ 

513 folder = Path(folder) / "images" 

514 folder.mkdir(parents=True, exist_ok=True) 

515 

516 for m in gnd_data: 

517 d = np.log10(np.clip(gnd_data[m], a_min=1e-10, a_max=None)) 

518 mx = d.max() 

519 for i in range(d.shape[0]): 

520 img_array = (d[i] / mx * 255).astype(np.uint8) 

521 img = Image.fromarray(img_array) 

522 img.save(folder / f"gnd_{m}_{i}.png") 

523 

524 d_sum = d.sum(axis=0) 

525 img_array = (d_sum / d_sum.max() * 255).astype(np.uint8) 

526 img = Image.fromarray(img_array) 

527 img.save(folder / f"gnd_{m}_sum.png") 

528 

529 fdm_avg = fdm_data.mean(axis=0) 

530 img_array = (fdm_avg / fdm_avg.max() * 255).astype(np.uint8) 

531 img = Image.fromarray(img_array) 

532 img.save(folder / "fdm_avg.png") 

533 

534 fdm_max = fdm_data.max(axis=0) 

535 img_array = (fdm_max / fdm_max.max() * 255).astype(np.uint8) 

536 img = Image.fromarray(img_array) 

537 img.save(folder / "fdm_max.png") 

538 return