Coverage for src/arcade_collection/convert/convert_to_meshes.py: 100%

100 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2024-12-09 19:07 +0000

1from __future__ import annotations 

2 

3from enum import Enum 

4from typing import TYPE_CHECKING 

5 

6import numpy as np 

7from skimage import measure 

8 

9from arcade_collection.output.extract_tick_json import extract_tick_json 

10from arcade_collection.output.get_location_voxels import get_location_voxels 

11 

12if TYPE_CHECKING: 

13 import tarfile 

14 

15 import pandas as pd 

16 

17MAX_ARRAY_LEVEL = 7 

18"""Maximum array level for conversion to meshes.""" 

19 

20 

21class MeshType(Enum): 

22 """Mesh face types.""" 

23 

24 DEFAULT = False 

25 """Mesh with default faces.""" 

26 

27 INVERTED = True 

28 """Mesh with inverted faces.""" 

29 

30 

31def convert_to_meshes( 

32 series_key: str, 

33 locations_tar: tarfile.TarFile, 

34 frame_spec: tuple[int, int, int], 

35 regions: list[str], 

36 box: tuple[int, int, int], 

37 mesh_type: MeshType | dict[str, MeshType] = MeshType.DEFAULT, 

38 group_size: int | None = None, 

39 categories: pd.DataFrame | None = None, 

40) -> list[tuple[int, int, str, str]]: 

41 """ 

42 Convert data to mesh OBJ contents. 

43 

44 Parameters 

45 ---------- 

46 series_key 

47 Simulation series key. 

48 locations_tar 

49 Archive of location data. 

50 frame_spec 

51 Specification for mesh frames. 

52 regions 

53 List of regions. 

54 box 

55 Size of bounding box. 

56 mesh_type 

57 Mesh face type. 

58 group_size 

59 Number of objects in each group (if grouping meshes). 

60 categories 

61 Simulation data containing ID, FRAME, and CATEGORY. 

62 

63 Returns 

64 ------- 

65 : 

66 List of mesh frames, indices, regions, and OBJ contents. 

67 """ 

68 

69 frames = list(np.arange(*frame_spec)) 

70 meshes = [] 

71 

72 length, width, height = box 

73 groups = make_mesh_groups(categories, frames, group_size) if group_size is not None else None 

74 

75 for frame in frames: 

76 locations = extract_tick_json(locations_tar, series_key, frame, "LOCATIONS") 

77 

78 for region in regions: 

79 region_mesh_type = mesh_type[region] if isinstance(mesh_type, dict) else mesh_type 

80 

81 if groups is None: 

82 for location in locations: 

83 location_id = location["id"] 

84 mesh = make_individual_mesh( 

85 location, length, width, height, region, region_mesh_type 

86 ) 

87 

88 if mesh is None: 

89 continue 

90 

91 meshes.append((frame, location_id, region, mesh)) 

92 else: 

93 for index, group in groups[frame].items(): 

94 group_locations = [ 

95 location for location in locations if location["id"] in group 

96 ] 

97 mesh = make_combined_mesh( 

98 group_locations, length, width, height, region, region_mesh_type 

99 ) 

100 

101 if mesh is None: 

102 continue 

103 

104 meshes.append((frame, index, region, mesh)) 

105 

106 return meshes 

107 

108 

109def make_mesh_groups( 

110 categories: pd.DataFrame, frames: list[int], group_size: int 

111) -> dict[int, dict[int, list[int]]]: 

112 """ 

113 Group objects based on group size and categories. 

114 

115 Parameters 

116 ---------- 

117 categories 

118 Simulation data containing ID, FRAME, and CATEGORY. 

119 frames 

120 List of frames. 

121 group_size 

122 Number of objects in each group. 

123 

124 Returns 

125 ------- 

126 : 

127 Map of frame to map of index to location ids. 

128 """ 

129 

130 groups: dict[int, dict[int, list[int]]] = {} 

131 

132 for frame in frames: 

133 groups[frame] = {} 

134 frame_categories = categories[categories["FRAME"] == frame] 

135 index_offset = 0 

136 

137 for _, category_group in frame_categories.groupby("CATEGORY"): 

138 ids = list(category_group["ID"].values) 

139 group_ids = [ids[i : i + group_size] for i in range(0, len(ids), group_size)] 

140 groups[frame].update({i + index_offset: group for i, group in enumerate(group_ids)}) 

141 

142 index_offset = index_offset + len(group_ids) 

143 

144 return groups 

145 

146 

147def make_individual_mesh( 

148 location: dict, 

149 length: int, 

150 width: int, 

151 height: int, 

152 region: str, 

153 mesh_type: MeshType = MeshType.DEFAULT, 

154) -> str | None: 

155 """ 

156 Create mesh containing a single object. 

157 

158 Parameters 

159 ---------- 

160 location 

161 Location object. 

162 length 

163 Bounding box length. 

164 width 

165 Bounding box width. 

166 height 

167 Bounding box height. 

168 region 

169 Region name. 

170 mesh_type 

171 Mesh face type. 

172 

173 Returns 

174 ------- 

175 : 

176 Single mesh OBJ file contents. 

177 """ 

178 

179 voxels = [ 

180 (x, width - y - 1, z) 

181 for x, y, z in get_location_voxels(location, region if region != "DEFAULT" else None) 

182 ] 

183 

184 if len(voxels) == 0: 

185 return None 

186 

187 center = list(np.array(voxels).mean(axis=0)) 

188 array = make_mesh_array(voxels, length, width, height) 

189 verts, faces, normals = make_mesh_geometry(array, center) 

190 return make_mesh_file(verts, faces, normals, mesh_type) 

191 

192 

193def make_combined_mesh( 

194 locations: list[dict], 

195 length: int, 

196 width: int, 

197 height: int, 

198 region: str, 

199 mesh_type: MeshType = MeshType.DEFAULT, 

200) -> str | None: 

201 """ 

202 Create mesh containing multiple objects. 

203 

204 Parameters 

205 ---------- 

206 locations 

207 List of location objects. 

208 length 

209 Bounding box length. 

210 width 

211 Bounding box width. 

212 height 

213 Bounding box height. 

214 region 

215 Region name. 

216 mesh_type 

217 Mesh face type. 

218 

219 Returns 

220 ------- 

221 : 

222 Combined mesh OBJ file contents. 

223 """ 

224 

225 meshes = [] 

226 offset = 0 

227 

228 for location in locations: 

229 voxels = [ 

230 (x, width - y - 1, z) 

231 for x, y, z in get_location_voxels(location, region if region != "DEFAULT" else None) 

232 ] 

233 

234 if len(voxels) == 0: 

235 continue 

236 

237 center = [length / 2, width / 2, height / 2] 

238 array = make_mesh_array(voxels, length, width, height) 

239 verts, faces, normals = make_mesh_geometry(array, center, offset) 

240 mesh = make_mesh_file(verts, faces, normals, mesh_type) 

241 

242 meshes.append(mesh) 

243 offset = offset + len(verts) 

244 

245 combined_mesh = "\n".join(meshes) 

246 

247 return combined_mesh if meshes else None 

248 

249 

250def make_mesh_array( 

251 voxels: list[tuple[int, int, int]], length: int, width: int, height: int 

252) -> np.ndarray: 

253 """ 

254 Generate array from list of voxels. 

255 

256 Given voxel locations are set to the max array level. The array is smoothed 

257 such that all other locations are set to the number of max-level neighbors. 

258 

259 Parameters 

260 ---------- 

261 voxels 

262 List of voxels representing object. 

263 length 

264 Bounding box length. 

265 width 

266 Bounding box width. 

267 height 

268 Bounding box height. 

269 

270 Returns 

271 ------- 

272 : 

273 Array representing object. 

274 """ 

275 

276 # Create array. 

277 array = np.zeros((length, width, height), dtype=np.uint8) 

278 array[tuple(np.transpose(voxels))] = MAX_ARRAY_LEVEL 

279 

280 # Get set of zero neighbors for all voxels. 

281 offsets = [(-1, 0, 0), (1, 0, 0), (0, -1, 0), (0, 1, 0), (0, 0, -1), (0, 0, 1)] 

282 neighbors = { 

283 (x + i, y + j, z + k) 

284 for x, y, z in voxels 

285 for i, j, k in offsets 

286 if array[x + i, y + j, z + k] == 0 

287 } 

288 

289 # Remove invalid neighbors on borders. 

290 neighbors = { 

291 (x, y, z) 

292 for x, y, z in neighbors 

293 if x != 0 and y != 0 and z != 0 and x != length - 1 and y != width - 1 and z != height - 1 

294 } 

295 

296 # Smooth array levels based on neighbor counts. 

297 for x, y, z in neighbors: 

298 array[x, y, z] = ( 

299 sum(array[x + i, y + j, z + k] == MAX_ARRAY_LEVEL for i, j, k in offsets) + 1 

300 ) 

301 

302 return array 

303 

304 

305def make_mesh_geometry( 

306 array: np.ndarray, center: list[float], offset: int = 0 

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

308 """ 

309 Generate mesh from array. 

310 

311 Parameters 

312 ---------- 

313 array 

314 Array representing object. 

315 center 

316 Coordinate of object center. 

317 offset 

318 Offset for face indices. 

319 

320 Returns 

321 ------- 

322 : 

323 Arrays of mesh vertices, faces, and normals. 

324 """ 

325 

326 level = int(MAX_ARRAY_LEVEL / 2) 

327 verts, faces, normals, _ = measure.marching_cubes(array, level=level, allow_degenerate=False) 

328 

329 # Center the vertices. 

330 verts[:, 0] = verts[:, 0] - center[0] 

331 verts[:, 1] = verts[:, 1] - center[1] 

332 verts[:, 2] = verts[:, 2] - center[2] 

333 

334 # Adjust face indices. 

335 faces = faces + 1 + offset 

336 

337 return verts, faces, normals 

338 

339 

340def make_mesh_file( 

341 verts: np.ndarray, 

342 faces: np.ndarray, 

343 normals: np.ndarray, 

344 mesh_type: MeshType = MeshType.DEFAULT, 

345) -> str: 

346 """ 

347 Create mesh OBJ file contents from marching cubes output. 

348 

349 If 

350 

351 Parameters 

352 ---------- 

353 verts 

354 Array of mesh vertices. 

355 faces 

356 Array of mesh faces. 

357 normals 

358 Array of mesh normals. 

359 mesh_type 

360 Mesh face type. 

361 

362 Returns 

363 ------- 

364 : 

365 Mesh OBJ file. 

366 """ 

367 

368 mesh = "" 

369 

370 for item in verts: 

371 mesh += f"v {item[0]} {item[1]} {item[2]}\n" 

372 

373 for item in normals: 

374 mesh += f"vn {item[0]} {item[1]} {item[2]}\n" 

375 

376 for item in faces: 

377 if mesh_type == MeshType.INVERTED: 

378 mesh += f"f {item[0]}//{item[0]} {item[1]}//{item[1]} {item[2]}//{item[2]}\n" 

379 else: 

380 mesh += f"f {item[2]}//{item[2]} {item[1]}//{item[1]} {item[0]}//{item[0]}\n" 

381 

382 return mesh