Coverage for model_workflow/tools/get_screenshot.py: 87%

205 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-23 10:54 +0000

1from subprocess import run, PIPE 

2from os import environ, remove 

3from os.path import exists 

4from PIL import Image 

5import math 

6import numpy as np 

7 

8from model_workflow.utils.auxiliar import ToolError 

9from model_workflow.utils.type_hints import * 

10 

11# The Convex Hull is a polygon which covers all the given point and Convex Hull is the smallest polygon.  

12# SciPy provides a method "scipy. spatial. ConvexHull()" to create a Convex Hull 

13from scipy.spatial import ConvexHull 

14from scipy.spatial.distance import cdist 

15 

16# Auxiliar files 

17AUXILIAR_PDB_FILENAME = '.screenshot_structure.pdb' 

18AUXILIAR_TGA_FILENAME = '.transition_screenshot.tga' 

19 

20# Use this to draw some shapes as references in the screenshot 

21# This is useful for debugging purposes only 

22debug = False 

23 

24 

25#  

26def get_screenshot ( 

27 structure : 'Structure', 

28 output_filepath : str, 

29 # You may pass the camera rotation, translation and zoom parameters so they are not calculated again 

30 # This is useful to keep screenshots coherent between different clusters/markov states 

31 # Note that a slight movement in the molecule may make the rotation logic here use a different angle 

32 # Thus the image could be radically different and misleading, since the change could be minimal 

33 parameters : Optional[dict] = None, 

34) -> dict: 

35 """ Obtain a screenshot from the pdb file using VMD. This screenshot of the system is uploaded to the database. 

36 Returns the rotation values used to take the photo so they can be saved and reused.""" 

37 # Check the output screenshot file extension is JPG 

38 if output_filepath.split('.')[-1] != 'jpg': 

39 raise SystemExit('You must provide a .jpg file name!') 

40 

41 # Produce a PDB file to feed VMD 

42 structure.generate_pdb_file(AUXILIAR_PDB_FILENAME) 

43 

44 # Number of pixels to scale in x  

45 x_number_pixels = 350 

46 # Number of pixels to scale in y 

47 y_number_pixels = 350 

48 

49 # Save the current value of the VMDSCRSIZE enviornmental variable 

50 VMDSCRSIZE_backup = environ.get('VMDSCRSIZE', '') 

51 

52 # Set an enviornment variable to handle the window size 

53 # WARNING: Use this system instead of the 'display resize' command 

54 # This command silently kills VMD when it is run in sbatch (but not in salloc) 

55 # The cause is not clear but this command may depend on OpenGL rendering features which are not supported in sbatch 

56 # https://www.ks.uiuc.edu/Training/Tutorials/vmd/tutorial-html/node8.html 

57 environ['VMDSCRSIZE'] = str(x_number_pixels) + " " + str(y_number_pixels) 

58 

59 # Prepare a Tcl script in order to execute different commands in the terminal of VMD 

60 

61 # Generate a file name for the commands file 

62 commands_filename_1 = '.commands_1.vmd' 

63 

64 # Set a file to save the result obtained when calculating the center 

65 center_filename = '.center_point_filename.txt' 

66 with open(commands_filename_1, "w") as file: 

67 # Select the whole molecule 

68 file.write('set sel [atomselect 0 all] \n') 

69 # Find the center 

70 file.write('set center [measure center $sel] \n') 

71 # Export the center to a file 

72 file.write(f'set center_file [open {center_filename} w] \n') 

73 file.write('puts $center_file $center \n') 

74 # Exit VMD  

75 file.write('exit \n') 

76 

77 # Run VMD 

78 process = run([ 

79 "vmd", 

80 AUXILIAR_PDB_FILENAME, 

81 "-e", 

82 commands_filename_1, 

83 "-dispdev", 

84 "none" 

85 ], stdout=PIPE, stderr=PIPE) 

86 logs = process.stdout.decode() 

87 

88 # If the center file was not generated then something went wrong with VMD 

89 if not exists(center_filename): 

90 print(logs) 

91 error_logs = process.stderr.decode() 

92 print(error_logs) 

93 raise ToolError('Something went wrong with VMD while generating the center point file') 

94 

95 # Read the generated file to get the center 

96 with open(center_filename,"r") as file: 

97 line = file.readline().split() 

98 line = [float(i) for i in line] 

99 vmd_center_coordinates = line 

100 

101 # Set the camera rotation, translation and zoom values to get the optimal picture 

102 angle = None 

103 angle2 = None 

104 y_axis_difference_vector = None 

105 x_axis_difference_vector = None 

106 scale = None 

107 

108 # If precalculated values are passed then use them 

109 if parameters: 

110 angle = parameters['angle'] 

111 angle2 = parameters['angle2'] 

112 y_axis_difference_vector = parameters['y_axis_difference_vector'] 

113 x_axis_difference_vector = parameters['x_axis_difference_vector'] 

114 scale = parameters['scale'] 

115 

116 # We must calculate these values otherwise 

117 else: 

118 

119 # Obtain all coordinates from each atom of the filtered structure 

120 coordinates = [list(atom.coords) for atom in structure.atoms] 

121 # Convert the list into a Numpy Array, since Scipy library just works with this type of data structure 

122 coordinates_np = np.array(coordinates) 

123 

124 # Iterate over all coordinates to store in a dictionary as a key a tuple with the coordinates with the index as its value 

125 d = {} 

126 for index,coordinate in enumerate(coordinates): 

127 d[tuple(coordinate)] = index 

128 

129 # Compute the hull of the molecule 

130 hull = ConvexHull(coordinates_np) 

131 # Extract the points forming the hull 

132 hullpoints = coordinates_np[hull.vertices,:] 

133 # Naive way of finding the best pair in O(H^2) time if H is number of points on hull 

134 hull_best_points = cdist(hullpoints, hullpoints, metric='euclidean') 

135 # Get the farthest apart points 

136 bestpair = np.unravel_index(hull_best_points.argmax(), hull_best_points.shape) 

137 # Convert the first point coordinates from Numpy array to list 

138 first_point = hullpoints[bestpair[0]].tolist() 

139 # Now into tuple  

140 first_point = tuple(first_point) 

141 # Convert the second point coordinates from Numpy array to list 

142 second_point = hullpoints[bestpair[1]].tolist() 

143 # Now into tuple  

144 second_point = tuple(second_point) 

145 

146 ### TRIGONOMETRY TO COMPUTE THE ANGLE WE NEED TO ROTATE 

147 

148 # FIRST ROTATION 

149 # Looking at the x-z plane, consider we have a rectangle triangle where the segment between the 

150 # first and second points is the hypotenuse and the sides of traingle are paralel to x and z axes 

151 # Calculate the grades we must rotate the molecule to align the two points in the x axis 

152 

153 # Get the hypotenuse 

154 # Note that the hypotenuse is calculated as a projection of the segment in the x-z plane 

155 # This is lower than the actual distance between the points 

156 hypotenuse = calculate_distance(first_point, second_point, ['x', 'z']) 

157 

158 # Get the z-side which it is just the difference in the z coordinates 

159 z_side = abs(first_point[2] - second_point[2]) 

160 

161 # Obtain the angle that we are interested in order to rotate the molecule 

162 # Apply trigonometry, compute the arcsinus of the division between the opposite side and the hypothenuse  

163 angle = math.degrees(math.asin(z_side/hypotenuse)) if hypotenuse > 0 else 0 

164 

165 # Set the right rotation direction 

166 most_negative_x_point = first_point if first_point[0] <= second_point[0] else second_point 

167 most_negative_z_point = first_point if first_point[2] <= second_point[2] else second_point 

168 if most_negative_x_point != most_negative_z_point: 

169 angle = -angle 

170 

171 # SECOND ROTATION 

172 # Now looking at the x-y plane, consider we have a rectangle triangle where the segment between the 

173 # first and second points is the hypotenuse and the sides of traingle are paralel to x and y axes 

174 # Calculate the grades we must rotate the molecule to align the two points in the x axis 

175 

176 # Note that once the rotation for this triangle is solved we add an extra 45 grades 

177 # This is becase we want the molecule to be aligned with the diagonal of the image, not the x axis 

178 

179 # Get the hypotenuse  

180 # Note that here we use all dimensions and not the projection in x-y plane 

181 # This is because VMD rotates using its current rotation as reference, not the absolute 

182 # Thus the segment is already in "our" x-y plane after the previous rotation 

183 # Actually removing the z would be wrong since this z is not "our" z 

184 # It is hard to understand only with words but trust 

185 hypotenuse2 = calculate_distance(first_point, second_point, ['x', 'y', 'z']) 

186 

187 # Get the y-side which it is just the difference in the y coordinates 

188 y_side = abs(first_point[1] - second_point[1]) 

189 

190 # Obtain the angle that we are interested in order to rotate the molecule 

191 # Apply trigonometry, compute the arcsinus of the division between the opposite side and the hypothenuse 

192 angle2 = math.degrees(math.asin(y_side/hypotenuse2)) if hypotenuse2 > 0 else 0 

193 

194 # Set the right rotation direction 

195 most_negative_x_point = first_point if first_point[0] <= second_point[0] else second_point 

196 most_positive_y_point = first_point if first_point[1] >= second_point[1] else second_point 

197 if most_negative_x_point != most_positive_y_point: 

198 angle2 = -angle2 

199 # As we want it diagonal with respect y and x we add 45 degrees 

200 angle2 += 45 

201 

202 # Vector that we are going to use as x axis  

203 absolute_x_axis = (1,0,0) 

204 # Vector that we are going to use as y axis  

205 absolute_y_axis = (0,1,0) 

206 

207 # Initial normal vector representing the direction of the camera view 

208 # Note that when we enter VMD the z axis is pointing towards us 

209 initial_normal_vector = (0,0,-1) 

210 

211 # Rotate normal vector in order to have it in the same direction as the camera after the first rotation 

212 # Note that the angle here and below is negative 

213 # Rotating in VMD by the y axis is counter intuitive: negative means clockwise 

214 rotated_normal_vector = rotate_vector(initial_normal_vector, -angle, absolute_y_axis) 

215 

216 # Rotate x axis around y axis 

217 first_rotated_x_axis = rotate_vector(absolute_x_axis, -angle, absolute_y_axis) 

218 # Note that there is no need to apply the first rotation to the y axis since we rotate around the y axis 

219 

220 # Now, for the second rotation we must use the rotated normal vector as pivot 

221 # This emulates how VMD rotates around the recently moved camera axis, not the global axis 

222 # Obtain rotation matrix to perform the second rotation we have done previously  

223 rotated_x_axis = rotate_vector(first_rotated_x_axis, angle2, rotated_normal_vector) 

224 # Perform just the same procedure as before 

225 rotated_y_axis = rotate_vector(absolute_y_axis, angle2, rotated_normal_vector) 

226 

227 ########################################################## 

228 

229 # Project all atom coordinates in each rotated axis to find the range of coordinates and thus its center 

230 # Formula from https://gamedev.stackexchange.com/questions/72528/how-can-i-project-a-3d-point-onto-a-3d-line 

231 # A + dot(AP,AB) / dot(AB,AB) * AB 

232 

233 # The point to set the line does not matter. It could be (0,0,0) 

234 # However, we use the vmd center so then we can take the projected points as references for debugging 

235 A_point = vmd_center_coordinates 

236 

237 # As we are going to compute a line in Y axis we just set AB_vector equal to the rotated y axis  

238 AB_vector = rotated_y_axis 

239 

240 # Function to project a point into a line 

241 # WARNING: It has been observed experimentally that this function is not working as expected 

242 # WARNING: Points are projected in the line but very shifted 

243 # WARNING: However the error is the same for the molecule points and the view center 

244 # WARNING: For this reason the resulting difference is correct and it works 

245 def get_projected_point (P_point : Tuple[float, float, float]) -> Tuple[float, float, float]: 

246 AP_vector = [A_point[i] + P_point[i] for i in range(3)] 

247 AP_AB_dot_product = np.dot(AP_vector, AB_vector) 

248 AB_AB_dot_product = np.dot(AB_vector, AB_vector) 

249 scalar = AP_AB_dot_product / AB_AB_dot_product 

250 projected_vector = [AB_vector[i] * scalar for i in range(3)] 

251 projected_point = [A_point[i] + projected_vector[i] for i in range(3)] 

252 return projected_point 

253 

254 # Project all atom coordinates in the rotated y axis 

255 projected_points = [ get_projected_point(P_point) for P_point in coordinates ] 

256 projected_points.sort(key=lambda projected_points: projected_points[0]) 

257 projected_points.sort(key=lambda projected_points: projected_points[1]) 

258 projected_points.sort(key=lambda projected_points: projected_points[2]) 

259 # Get the most distant projected points 

260 max_ypoint = projected_points[0] 

261 min_ypoint = projected_points[-1] 

262 # Find the center between the most distant points 

263 y_axis_center = [(max_ypoint[i] + min_ypoint[i]) / 2 for i in range(3)] 

264 

265 # Project the center of the view in the rotated y axis 

266 y_axis_projected_center = get_projected_point(vmd_center_coordinates) 

267 # Calculate the difference between the molecule center and the view center in the rotated y axis 

268 y_axis_difference_vector = [y_axis_projected_center[i] - y_axis_center[i] for i in range(3)] 

269 

270 # Repeat the whole process with the rotated x axis 

271 

272 # Project all atom coordinates in the rotated x axis 

273 AB_vector = rotated_x_axis 

274 projected_points2 = [ get_projected_point(P_point) for P_point in coordinates ] 

275 projected_points2.sort(key=lambda projected_points2: projected_points2[0]) 

276 projected_points2.sort(key=lambda projected_points2: projected_points2[1]) 

277 projected_points2.sort(key=lambda projected_points2: projected_points2[2]) 

278 # Get the most distant projected points 

279 max_xpoint = projected_points2[0] 

280 min_xpoint = projected_points2[-1] 

281 # Find the center between the most distant points 

282 x_axis_center = [(max_xpoint[i] + min_xpoint[i]) / 2 for i in range(3)] 

283 

284 # Project the center of the view in the rotated x axis 

285 x_axis_projected_center = get_projected_point(vmd_center_coordinates) 

286 # Calculate the difference between the molecule center and the view center in the rotated x axis 

287 x_axis_difference_vector = [x_axis_projected_center[i] - x_axis_center[i] for i in range(3)] 

288 

289 # Calculate height and width and get the widest dimension 

290 width = calculate_distance(max_xpoint, min_xpoint, ['x','y','z']) 

291 height = calculate_distance(max_ypoint, min_ypoint, ['x','y','z']) 

292 widest = max(width, height) 

293 # Set how close to the molecule we want the camera to be 

294 # This value has been found experimentally and it keeps a small white margin 

295 zoom = 2.8 

296 # Set the scale 

297 scale = zoom / widest 

298 

299 # We must find also what is representable through cartoon and what is not 

300 # Relying in 'protein or nucleic' is not safe enough 

301 # For some structures large regions could remain invisible 

302 # e.g. a large peptide noted as a single residue 

303 # Note that we also include terminals in the cartoon selection although they are not representable 

304 # This is because terminals are better hidden than represented as ligands, this would be missleading 

305 cartoon_selection = structure.select_cartoon(include_terminals=True) 

306 non_cartoon_selection = structure.invert_selection(cartoon_selection) 

307 # Also coarse grain beads have to be considered 

308 # We cannot paint them by their elements so we must rely in atom names or chains 

309 cg_selection = structure.select_cg() 

310 non_cartoon_selection -= cg_selection 

311 # Set a file name for the VMD script file 

312 commands_filename_2 = '.commands_2.vmd' 

313 

314 # Now write the VMD script for the rotation 

315 with open(commands_filename_2, "w") as file: 

316 # Set the Background of the molecule to white color 

317 file.write('color Display Background white \n') 

318 # Delete the axes drawing in the VMD window 

319 file.write('axes location Off \n') 

320 # Eliminate the molecule to perform changes in the representation, color and material  

321 file.write('mol delrep 0 top \n') 

322 # First add a spcific representation for polymers (protein and nucleic acids) 

323 if cartoon_selection: 

324 # Change the default representation model to Newcartoon 

325 file.write('mol representation Newcartoon \n') 

326 # Change the default atom coloring method setting to Chain 

327 file.write('mol color Chain \n') 

328 # Set the default atom selection to atoms to be represented as cartoon 

329 file.write(f'mol selection "{cartoon_selection.to_vmd()}" \n') 

330 # Change the current material of the representation of the molecule 

331 file.write('mol material Opaque \n') 

332 # Using the new changes performed previously add a new representation to the new molecule 

333 file.write('mol addrep top \n') 

334 # In case we have any non-cartoon selection to represent... 

335 if non_cartoon_selection: 

336 # Change the default representation model to CPK (ball and stick) 

337 file.write('mol representation cpk \n') 

338 # Change the default atom coloring method setting to Chain 

339 file.write('mol color element \n') 

340 # Set the default atom selection to atoms to be represented as CPK 

341 file.write(f'mol selection "{non_cartoon_selection.to_vmd()}" \n') 

342 # Change the current material of the representation of the molecule 

343 file.write('mol material Opaque \n') 

344 # Using the new changes performed previously add a new representation to the new molecule 

345 file.write('mol addrep top \n') 

346 # In case we have coarse grain beads 

347 if cg_selection: 

348 # Change the default representation model to CPK (ball and stick) 

349 file.write('mol representation cpk \n') 

350 # Change the default atom coloring method setting to Chain 

351 file.write('mol color chain \n') 

352 # Set the default atom selection to atoms to be represented as CPK 

353 file.write(f'mol selection "{cg_selection.to_vmd()}" \n') 

354 # Change the current material of the representation of the molecule 

355 file.write('mol material Opaque \n') 

356 # Using the new changes performed previously add a new representation to the new molecule 

357 file.write('mol addrep top \n') 

358 # Change projection from perspective (used by VMD by default) to orthographic 

359 file.write('display projection orthographic \n') 

360 # Select all atoms 

361 file.write('set sel [atomselect 0 all] \n') 

362 # First rotation of the molecule to set it perpendicular with respect to z axis 

363 file.write(f'rotate y by {angle} \n') 

364 # Second rotatoin of the molecule to set it diagonal with respect to z axis 

365 file.write(f'rotate z by {angle2} \n') 

366 # Move to rectify the difference 

367 file.write('$sel moveby { ' + tuple_to_vmd(y_axis_difference_vector) + ' } \n') 

368 # Move to rectify the difference 

369 file.write('$sel moveby { ' + tuple_to_vmd(x_axis_difference_vector) + ' } \n') 

370 # Set the scale 

371 file.write(f'scale to {scale} \n') 

372 

373 # Show the theoretical view center if we are debugging 

374 if debug: 

375 # Note that all draw commands work with coordinates and thus they are absolute, not relative to camera 

376 # Draw the center 

377 file.write('draw sphere {' + tuple_to_vmd(vmd_center_coordinates) + '} radius 5\n') 

378 # Set a function to display vectors 

379 def display_vector (vector : tuple, color : str): 

380 ref_right_vector = multiply_vector(vector, 100) 

381 ref_right_point = point_adds_vector(vmd_center_coordinates, ref_right_vector) 

382 ref_left_vector = multiply_vector(vector, -100) 

383 ref_left_point = point_adds_vector(vmd_center_coordinates, ref_left_vector) 

384 file.write('draw color ' + color + '\n') 

385 file.write('draw line {' + tuple_to_vmd(ref_right_point) + '} {' + tuple_to_vmd(ref_left_point) + '}\n') 

386 # Draw the rotated axes 

387 #display_vector(first_rotated_x_axis, 'orange') 

388 display_vector(rotated_x_axis, 'red') 

389 display_vector(rotated_y_axis, 'green') 

390 # Draw projected point ranges 

391 # file.write('draw color red\n') 

392 # file.write('draw line {' + tuple_to_vmd(min_xpoint) + '} {' + tuple_to_vmd(max_xpoint) + '}\n') 

393 # file.write('draw color green\n') 

394 # file.write('draw line {' + tuple_to_vmd(min_ypoint) + '} {' + tuple_to_vmd(max_ypoint) + '}\n') 

395 

396 # Finally generate the image from the current view 

397 file.write(f'render TachyonInternal {AUXILIAR_TGA_FILENAME} \n') 

398 # Exit VMD 

399 file.write('exit\n') 

400 

401 # Run VMD 

402 process = run([ 

403 "vmd", 

404 AUXILIAR_PDB_FILENAME, 

405 "-e", 

406 commands_filename_2, 

407 "-dispdev", 

408 "none" 

409 ], stdout=PIPE, stderr=PIPE) 

410 logs = process.stdout.decode() 

411 # If the output file does not exist at this point then it means something went wrong with VMD 

412 if not exists(AUXILIAR_TGA_FILENAME): 

413 print(logs) 

414 error_logs = process.stderr.decode() 

415 print(error_logs) 

416 raise SystemExit('Something went wrong with VMD while taking the screenshot') 

417 

418 im = Image.open(AUXILIAR_TGA_FILENAME) 

419 # converting to jpg 

420 rgb_im = im.convert("RGB") 

421 # exporting the image 

422 rgb_im.save(output_filepath) 

423 

424 # Remove trash files 

425 trash_files = [ AUXILIAR_PDB_FILENAME, commands_filename_1, commands_filename_2, AUXILIAR_TGA_FILENAME, center_filename ] 

426 for trash_file in trash_files: 

427 remove(trash_file) 

428 

429 # Restore the environment variable to not cause problem in possible future uses of VMD 

430 environ['VMDSCRSIZE'] = VMDSCRSIZE_backup 

431 

432 # Return the camera rotation, translation and zoom values we just used to get the picture 

433 return { 

434 'angle': angle, 

435 'angle2': angle2, 

436 'y_axis_difference_vector': y_axis_difference_vector, 

437 'x_axis_difference_vector': x_axis_difference_vector, 

438 'scale': scale 

439 } 

440 

441# Set a function to write a tuple of numbers (point coorinates) as VMD expect these value: a string separated by space 

442def tuple_to_vmd (point : tuple) -> str: 

443 return str(point[0]) + ' ' + str(point[1]) + ' ' + str(point[2]) 

444 

445# Return the rotated vector 

446# https://stackoverflow.com/questions/6802577/rotation-of-3d-vector 

447def rotate_vector (vector : tuple, angle : float, axis : tuple) -> tuple: 

448 radians = math.radians(angle) 

449 axis = np.asarray(axis) 

450 axis = axis / math.sqrt(np.dot(axis, axis)) 

451 a = math.cos(radians / 2.0) 

452 b, c, d = -axis * math.sin(radians / 2.0) 

453 aa, bb, cc, dd = a * a, b * b, c * c, d * d 

454 bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d 

455 rotation_matrix = np.array([[aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)], 

456 [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)], 

457 [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc]]) 

458 return np.dot(rotation_matrix, vector) 

459 

460 

461# Calculate the distance between two atoms 

462def calculate_distance (first_coordinates : tuple, second_coordinates : tuple, dimensions : List[str]) -> float: 

463 squared_distances_sum = 0 

464 for dimension, i in { 'x': 0, 'y': 1, 'z': 2 }.items(): 

465 if dimension not in dimensions: 

466 continue 

467 squared_distances_sum += (first_coordinates[i] - second_coordinates[i])**2 

468 return math.sqrt(squared_distances_sum) 

469 

470# Set a function to gen a new point from a point and vector 

471def point_adds_vector (point : tuple, vector : tuple) -> tuple: 

472 return tuple([ point[d] + vector[d] for d in range(3) ]) 

473 

474def multiply_vector (vector : tuple, multiplier : float) -> tuple: 

475 return tuple([vector[d] * multiplier for d in range(3)])