Coverage for python/lsst/meas/astrom/pessimistic_pattern_matcher_b_3D.py: 9%

195 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-04 02:28 -0800

1# This file is part of meas_astrom. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22__all__ = ["PessimisticPatternMatcherB"] 

23 

24import numpy as np 

25from scipy.optimize import least_squares 

26from scipy.spatial import cKDTree 

27from scipy.stats import sigmaclip 

28 

29import lsst.pipe.base as pipeBase 

30 

31from ._measAstromLib import construct_pattern_and_shift_rot_matrix 

32 

33 

34def _rotation_matrix_chi_sq(flattened_rot_matrix, 

35 pattern_a, 

36 pattern_b, 

37 max_dist_rad): 

38 """Compute the squared differences for least squares fitting. 

39 

40 Given a flattened rotation matrix, one N point pattern and another N point 

41 pattern to transform into to, compute the squared differences between the 

42 points in the two patterns after the rotation. 

43 

44 Parameters 

45 ---------- 

46 flattened_rot_matrix : `numpy.ndarray`, (9, ) 

47 A flattened array representing a 3x3 rotation matrix. The array is 

48 flattened to comply with the API of scipy.optimize.least_squares. 

49 Flattened elements are [[0, 0], [0, 1], [0, 2], [1, 0]...] 

50 pattern_a : `numpy.ndarray`, (N, 3) 

51 A array containing N, 3 vectors representing the objects we would like 

52 to transform into the frame of pattern_b. 

53 pattern_b : `numpy.ndarray`, (N, 3) 

54 A array containing N, 3 vectors representing the reference frame we 

55 would like to transform pattern_a into. 

56 max_dist_rad : `float` 

57 The maximum distance allowed from the pattern matching. This value is 

58 used as the standard error for the resultant chi values. 

59 

60 Returns 

61 ------- 

62 noralized_diff : `numpy.ndarray`, (9,) 

63 Array of differences between the vectors representing of the source 

64 pattern rotated into the reference frame and the converse. This is 

65 used to minimize in a least squares fitter. 

66 """ 

67 # Unflatten the rotation matrix 

68 rot_matrix = flattened_rot_matrix.reshape((3, 3)) 

69 # Compare the rotated source pattern to the references. 

70 rot_pattern_a = np.dot(rot_matrix, pattern_a.transpose()).transpose() 

71 diff_pattern_a_to_b = rot_pattern_a - pattern_b 

72 # Return the flattened differences and length tolerances for use in a least 

73 # squares fitter. 

74 return diff_pattern_a_to_b.flatten() / max_dist_rad 

75 

76 

77class PessimisticPatternMatcherB: 

78 """Class implementing a pessimistic version of Optimistic Pattern Matcher 

79 B (OPMb) from `Tabur (2007)`_, as described in `DMTN-031`_ 

80 

81 Parameters 

82 ---------- 

83 reference_array : `numpy.ndarray`, (N, 3) 

84 spherical points x, y, z of to use as reference objects for 

85 pattern matching. 

86 log : `lsst.log.Log` or `logging.Logger` 

87 Logger for outputting debug info. 

88 

89 Notes 

90 ----- 

91 The class loads and stores the reference object 

92 in a convenient data structure for matching any set of source objects that 

93 are assumed to contain each other. The pessimistic nature of the algorithm 

94 comes from requiring that it discovers at least two patterns that agree on 

95 the correct shift and rotation for matching before exiting. The original 

96 behavior of OPMb can be recovered simply. Patterns matched between the 

97 input datasets are n-spoked pinwheels created from n+1 points. Refer to 

98 `DMTN-031`_ for more details. 

99 

100 .. _Tabur (2007): https://doi.org/10.1071/AS07028 

101 .. _DMTN-031: https://dmtn-031.lsst.io/ 

102 """ 

103 

104 def __init__(self, reference_array, log): 

105 self._reference_array = reference_array 

106 self._n_reference = len(self._reference_array) 

107 self.log = log 

108 

109 if self._n_reference > 0: 

110 self._build_distances_and_angles() 

111 else: 

112 raise ValueError("No reference objects supplied") 

113 

114 def _build_distances_and_angles(self): 

115 """Create the data structures we will use to search for our pattern 

116 match in. 

117 

118 Throughout this function and the rest of the class we use id to 

119 reference the position in the input reference catalog and index to 

120 'index' into the arrays sorted on distance. 

121 """ 

122 # Create empty arrays to store our pair information per 

123 # reference object. 

124 self._dist_array = np.empty( 

125 int(self._n_reference * (self._n_reference - 1) / 2), 

126 dtype="float32") 

127 self._id_array = np.empty( 

128 (int(self._n_reference * (self._n_reference - 1) / 2), 2), 

129 dtype="uint16") 

130 

131 startIdx = 0 

132 # Loop over reference objects storing pair distances and ids. 

133 for ref_id, ref_obj in enumerate(self._reference_array): 

134 # Set the ending slicing index to the correct length for the 

135 # pairs we are creating. 

136 endIdx = startIdx + self._n_reference - 1 - ref_id 

137 

138 # Reserve and fill the ids of each reference object pair. 

139 # 16 bit is safe for the id array as the catalog input from 

140 # MatchPessimisticB is limited to a max length of 2 ** 16. 

141 self._id_array[startIdx:endIdx, 0] = ref_id 

142 self._id_array[startIdx:endIdx, 1] = np.arange(ref_id + 1, 

143 self._n_reference, 

144 dtype="uint16") 

145 

146 # Compute the vector deltas for each pair of reference objects. 

147 # Compute and store the distances. 

148 self._dist_array[startIdx:endIdx] = np.sqrt( 

149 ((self._reference_array[ref_id + 1:, :] 

150 - ref_obj) ** 2).sum(axis=1)) 

151 # Set startIdx of the slice to the end of the previous slice. 

152 startIdx = endIdx 

153 

154 # Sort each array on the pair distances for the initial 

155 # optimistic pattern matcher lookup. 

156 sorted_dist_args = self._dist_array.argsort() 

157 self._dist_array = self._dist_array[sorted_dist_args] 

158 self._id_array = self._id_array[sorted_dist_args] 

159 

160 def match(self, source_array, n_check, n_match, n_agree, 

161 max_n_patterns, max_shift, max_rotation, max_dist, 

162 min_matches, pattern_skip_array=None): 

163 """Match a given source catalog into the loaded reference catalog. 

164 

165 Given array of points on the unit sphere and tolerances, we 

166 attempt to match a pinwheel like pattern between these input sources 

167 and the reference objects this class was created with. This pattern 

168 informs of the shift and rotation needed to align the input source 

169 objects into the frame of the references. 

170 

171 Parameters 

172 ---------- 

173 source_array : `numpy.ndarray`, (N, 3) 

174 An array of spherical x,y,z coordinates and a magnitude in units 

175 of objects having a lower value for sorting. The array should be 

176 of shape (N, 4). 

177 n_check : `int` 

178 Number of sources to create a pattern from. Not all objects may be 

179 checked if n_match criteria is before looping through all n_check 

180 objects. 

181 n_match : `int` 

182 Number of objects to use in constructing a pattern to match. 

183 n_agree : `int` 

184 Number of found patterns that must agree on their shift and 

185 rotation before exiting. Set this value to 1 to recover the 

186 expected behavior of Optimistic Pattern Matcher B. 

187 max_n_patters : `int` 

188 Number of patterns to create from the input source objects to 

189 attempt to match into the reference objects. 

190 max_shift : `float` 

191 Maximum allowed shift to match patterns in arcseconds. 

192 max_rotation : `float` 

193 Maximum allowed rotation between patterns in degrees. 

194 max_dist : `float` 

195 Maximum distance in arcseconds allowed between candidate spokes in 

196 the source and reference objects. Also sets that maximum distance 

197 in the intermediate verify, pattern shift/rotation agreement, and 

198 final verify steps. 

199 pattern_skip_array : `int` 

200 Patterns we would like to skip. This could be due to the pattern 

201 being matched on a previous iteration that we now consider invalid. 

202 This assumes the ordering of the source objects is the same 

203 between different runs of the matcher which, assuming no object 

204 has been inserted or the magnitudes have changed, it should be. 

205 

206 Returns 

207 ------- 

208 output_struct : `lsst.pipe.base.Struct` 

209 Result struct with components 

210 

211 - ``matches`` : (N, 2) array of matched ids for pairs. Empty list if no 

212 match found (`numpy.ndarray`, (N, 2) or `list`) 

213 - ``distances_rad`` : Radian distances between the matched objects. 

214 Empty list if no match found (`numpy.ndarray`, (N,)) 

215 - ``pattern_idx``: Index of matched pattern. None if no match found 

216 (`int`). 

217 - ``shift`` : Magnitude for the shift between the source and reference 

218 objects in arcseconds. None if no match found (`float`). 

219 """ 

220 # Given our input source_array we sort on magnitude. 

221 sorted_source_array = source_array[source_array[:, -1].argsort(), :3] 

222 n_source = len(sorted_source_array) 

223 

224 # Initialize output struct. 

225 output_match_struct = pipeBase.Struct( 

226 match_ids=[], 

227 distances_rad=[], 

228 pattern_idx=None, 

229 shift=None, 

230 max_dist_rad=None,) 

231 

232 if n_source <= 0: 

233 self.log.warning("Source object array is empty. Unable to match. Exiting matcher.") 

234 return None 

235 

236 # To test if the shifts and rotations we find agree with each other, 

237 # we first create two test points situated at the top and bottom of 

238 # where the z axis on the sphere bisects the source catalog. 

239 test_vectors = self._compute_test_vectors(source_array[:, :3]) 

240 

241 # We now create an empty list of our resultant rotated vectors to 

242 # compare the different rotations we find. 

243 rot_vect_list = [] 

244 

245 # Convert the tolerances to values we will use in the code. 

246 max_cos_shift = np.cos(np.radians(max_shift / 3600.)) 

247 max_cos_rot_sq = np.cos(np.radians(max_rotation)) ** 2 

248 max_dist_rad = np.radians(max_dist / 3600.) 

249 

250 # Loop through the sources from brightest to faintest, grabbing a 

251 # chunk of n_check each time. 

252 for pattern_idx in range(np.min((max_n_patterns, 

253 n_source - n_match))): 

254 

255 # If this pattern is one that we matched on the past but we 

256 # now want to skip, we do so here. 

257 if pattern_skip_array is not None and \ 

258 np.any(pattern_skip_array == pattern_idx): 

259 self.log.debug( 

260 "Skipping previously matched bad pattern %i...", 

261 pattern_idx) 

262 continue 

263 # Grab the sources to attempt to create this pattern. 

264 pattern = sorted_source_array[ 

265 pattern_idx: np.min((pattern_idx + n_check, n_source)), :3] 

266 

267 # Construct a pattern given the number of points defining the 

268 # pattern complexity. This is the start of the primary tests to 

269 # match our source pattern into the reference objects. 

270 construct_return_struct = \ 

271 self._construct_pattern_and_shift_rot_matrix( 

272 pattern, n_match, max_cos_shift, max_cos_rot_sq, 

273 max_dist_rad) 

274 

275 # Our struct is None if we could not match the pattern. 

276 if construct_return_struct.ref_candidates is None or \ 

277 construct_return_struct.shift_rot_matrix is None or \ 

278 construct_return_struct.cos_shift is None or \ 

279 construct_return_struct.sin_rot is None: 

280 continue 

281 

282 # Grab the output data from the Struct object. 

283 ref_candidates = construct_return_struct.ref_candidates 

284 shift_rot_matrix = construct_return_struct.shift_rot_matrix 

285 cos_shift = construct_return_struct.cos_shift 

286 sin_rot = construct_return_struct.sin_rot 

287 

288 # If we didn't match enough candidates we continue to the next 

289 # pattern. 

290 if len(ref_candidates) < n_match: 

291 continue 

292 

293 # Now that we know our pattern and shift/rotation are valid we 

294 # store the the rotated versions of our test points for later 

295 # use. 

296 tmp_rot_vect_list = [] 

297 for test_vect in test_vectors: 

298 tmp_rot_vect_list.append(np.dot(shift_rot_matrix, test_vect)) 

299 # Since our test point are in the source frame, we can test if 

300 # their lengths are mostly preserved under the transform. 

301 if not self._test_pattern_lengths(np.array(tmp_rot_vect_list), 

302 max_dist_rad): 

303 continue 

304 

305 tmp_rot_vect_list.append(pattern_idx) 

306 rot_vect_list.append(tmp_rot_vect_list) 

307 

308 # Test if we have enough rotations, which agree, or if we 

309 # are in optimistic mode. 

310 if self._test_rotation_agreement(rot_vect_list, max_dist_rad) < \ 

311 n_agree - 1: 

312 continue 

313 

314 # Run the final verify step. 

315 match_struct = self._final_verify(source_array[:, :3], 

316 shift_rot_matrix, 

317 max_dist_rad, 

318 min_matches) 

319 if match_struct.match_ids is None or \ 

320 match_struct.distances_rad is None or \ 

321 match_struct.max_dist_rad is None: 

322 continue 

323 

324 # Convert the observed shift to arcseconds 

325 shift = np.degrees(np.arccos(cos_shift)) * 3600. 

326 # Print information to the logger. 

327 self.log.debug("Succeeded after %i patterns.", pattern_idx) 

328 self.log.debug("\tShift %.4f arcsec", shift) 

329 self.log.debug("\tRotation: %.4f deg", 

330 np.degrees(np.arcsin(sin_rot))) 

331 

332 # Fill the struct and return. 

333 output_match_struct.match_ids = \ 

334 match_struct.match_ids 

335 output_match_struct.distances_rad = \ 

336 match_struct.distances_rad 

337 output_match_struct.pattern_idx = pattern_idx 

338 output_match_struct.shift = shift 

339 output_match_struct.max_dist_rad = match_struct.max_dist_rad 

340 return output_match_struct 

341 

342 self.log.debug("Failed after %i patterns.", pattern_idx + 1) 

343 return output_match_struct 

344 

345 def _compute_test_vectors(self, source_array): 

346 """Compute spherical 3 vectors at the edges of the x, y, z extent 

347 of the input source catalog. 

348 

349 Parameters 

350 ---------- 

351 source_array : `numpy.ndarray`, (N, 3) 

352 array of 3 vectors representing positions on the unit 

353 sphere. 

354 

355 Returns 

356 ------- 

357 test_vectors : `numpy.ndarray`, (N, 3) 

358 Array of vectors representing the maximum extents in x, y, z 

359 of the input source array. These are used with the rotations 

360 the code finds to test for agreement from different patterns 

361 when the code is running in pessimistic mode. 

362 """ 

363 # Get the center of source_array. 

364 if np.any(np.logical_not(np.isfinite(source_array))): 

365 self.log.warning("Input source objects contain non-finite values. " 

366 "This could end badly.") 

367 center_vect = np.nanmean(source_array, axis=0) 

368 

369 # So that our rotation test works over the full sky we compute 

370 # the max extent in each Cartesian direction x,y,z. 

371 xbtm_vect = np.array([np.min(source_array[:, 0]), center_vect[1], 

372 center_vect[2]], dtype=np.float64) 

373 xtop_vect = np.array([np.max(source_array[:, 0]), center_vect[1], 

374 center_vect[2]], dtype=np.float64) 

375 xbtm_vect /= np.sqrt(np.dot(xbtm_vect, xbtm_vect)) 

376 xtop_vect /= np.sqrt(np.dot(xtop_vect, xtop_vect)) 

377 

378 ybtm_vect = np.array([center_vect[0], np.min(source_array[:, 1]), 

379 center_vect[2]], dtype=np.float64) 

380 ytop_vect = np.array([center_vect[0], np.max(source_array[:, 1]), 

381 center_vect[2]], dtype=np.float64) 

382 ybtm_vect /= np.sqrt(np.dot(ybtm_vect, ybtm_vect)) 

383 ytop_vect /= np.sqrt(np.dot(ytop_vect, ytop_vect)) 

384 

385 zbtm_vect = np.array([center_vect[0], center_vect[1], 

386 np.min(source_array[:, 2])], dtype=np.float64) 

387 ztop_vect = np.array([center_vect[0], center_vect[1], 

388 np.max(source_array[:, 2])], dtype=np.float64) 

389 zbtm_vect /= np.sqrt(np.dot(zbtm_vect, zbtm_vect)) 

390 ztop_vect /= np.sqrt(np.dot(ztop_vect, ztop_vect)) 

391 

392 # Return our list of vectors for later rotation testing. 

393 return np.array([xbtm_vect, xtop_vect, ybtm_vect, ytop_vect, 

394 zbtm_vect, ztop_vect]) 

395 

396 def _construct_pattern_and_shift_rot_matrix(self, src_pattern_array, 

397 n_match, max_cos_theta_shift, 

398 max_cos_rot_sq, max_dist_rad): 

399 """Test an input source pattern against the reference catalog. 

400 Returns the candidate matched patterns and their 

401 implied rotation matrices or None. 

402 

403 Parameters 

404 ---------- 

405 src_pattern_array : `numpy.ndarray`, (N, 3) 

406 Sub selection of source 3 vectors to create a pattern from 

407 n_match : `int` 

408 Number of points to attempt to create a pattern from. Must be 

409 >= len(src_pattern_array) 

410 max_cos_theta_shift : `float` 

411 Maximum shift allowed between two patterns' centers. 

412 max_cos_rot_sq : `float` 

413 Maximum rotation between two patterns that have been shifted 

414 to have their centers on top of each other. 

415 max_dist_rad : `float` 

416 Maximum delta distance allowed between the source and reference 

417 pair distances to consider the reference pair a candidate for 

418 the source pair. Also sets the tolerance between the opening 

419 angles of the spokes when compared to the reference. 

420 

421 Return 

422 ------- 

423 output_matched_pattern : `lsst.pipe.base.Struct` 

424 Result struct with components: 

425 

426 - ``ref_candidates`` : ids of the matched pattern in the internal 

427 reference_array object (`list` of `int`). 

428 - ``src_candidates`` : Pattern ids of the sources matched 

429 (`list` of `int`). 

430 - ``shift_rot_matrix_src_to_ref`` : 3x3 matrix specifying the full 

431 shift and rotation between the reference and source objects. 

432 Rotates source into reference frame. `None` if match is not 

433 found. (`numpy.ndarray`, (3, 3)) 

434 - ``shift_rot_matrix_ref_to_src`` : 3x3 matrix specifying the full 

435 shift and rotation of the reference and source objects. Rotates 

436 reference into source frame. None if match is not found 

437 (`numpy.ndarray`, (3, 3)). 

438 - ``cos_shift`` : Magnitude of the shift found between the two 

439 patten centers. `None` if match is not found (`float`). 

440 - ``sin_rot`` : float value of the rotation to align the already 

441 shifted source pattern to the reference pattern. `None` if no match 

442 found (`float`). 

443 """ 

444 # Create the delta vectors and distances we will need to assemble the 

445 # spokes of the pattern. 

446 src_delta_array = np.empty((len(src_pattern_array) - 1, 3)) 

447 src_delta_array[:, 0] = (src_pattern_array[1:, 0] 

448 - src_pattern_array[0, 0]) 

449 src_delta_array[:, 1] = (src_pattern_array[1:, 1] 

450 - src_pattern_array[0, 1]) 

451 src_delta_array[:, 2] = (src_pattern_array[1:, 2] 

452 - src_pattern_array[0, 2]) 

453 src_dist_array = np.sqrt(src_delta_array[:, 0]**2 

454 + src_delta_array[:, 1]**2 

455 + src_delta_array[:, 2]**2) 

456 

457 pattern_result = construct_pattern_and_shift_rot_matrix( 

458 src_pattern_array, src_delta_array, src_dist_array, 

459 self._dist_array, self._id_array, self._reference_array, n_match, 

460 max_cos_theta_shift, max_cos_rot_sq, max_dist_rad) 

461 

462 if pattern_result.success: 

463 candidate_array = np.array(pattern_result.candidate_pairs) 

464 fit_shift_rot_matrix = self._intermediate_verify( 

465 src_pattern_array[candidate_array[:, 1]], 

466 self._reference_array[candidate_array[:, 0]], 

467 pattern_result.shift_rot_matrix, max_dist_rad) 

468 return pipeBase.Struct(ref_candidates=candidate_array[:, 0].tolist(), 

469 src_candidates=candidate_array[:, 1].tolist(), 

470 shift_rot_matrix=fit_shift_rot_matrix, 

471 cos_shift=pattern_result.cos_shift, 

472 sin_rot=pattern_result.sin_rot) 

473 return pipeBase.Struct(ref_candidates=[], 

474 src_candidates=[], 

475 shift_rot_matrix=None, 

476 cos_shift=None, 

477 sin_rot=None) 

478 

479 def _intermediate_verify(self, src_pattern, ref_pattern, shift_rot_matrix, 

480 max_dist_rad): 

481 """ Perform an intermediate verify step. 

482 Rotate the matches references into the source frame and test their 

483 distances against tolerance. Only return true if all points are within 

484 tolerance. 

485 

486 Parameters 

487 ---------- 

488 src_pattern : `numpy.ndarray`, (N,3) 

489 Array of 3 vectors representing the points that make up our source 

490 pinwheel pattern. 

491 ref_pattern : `numpy.ndarray`, (N,3) 

492 Array of 3 vectors representing our candidate reference pinwheel 

493 pattern. 

494 shift_rot_matrix : `numpy.ndarray`, (3,3) 

495 3x3 rotation matrix that takes the source objects and rotates them 

496 onto the frame of the reference objects 

497 max_dist_rad : `float` 

498 Maximum distance allowed to consider two objects the same. 

499 

500 Returns 

501 ------- 

502 fit_shift_rot_matrix : `numpy.ndarray`, (3,3) 

503 Fitted shift/rotation matrix if all of the points in our 

504 source pattern are within max_dist_rad of their matched reference 

505 objects. Returns None if this criteria is not satisfied. 

506 """ 

507 if len(src_pattern) != len(ref_pattern): 

508 raise ValueError( 

509 "Source pattern length does not match ref pattern.\n" 

510 "\t source pattern len=%i, reference pattern len=%i" % 

511 (len(src_pattern), len(ref_pattern))) 

512 

513 if self._intermediate_verify_comparison( 

514 src_pattern, ref_pattern, shift_rot_matrix, max_dist_rad): 

515 # Now that we know our initial shift and rot matrix is valid we 

516 # want to fit the implied transform using all points from 

517 # our pattern. This is a more robust rotation matrix as our 

518 # initial matrix only used the first 2 points from the source 

519 # pattern to estimate the shift and rotation. The matrix we fit 

520 # are allowed to be non unitary but need to preserve the length of 

521 # the rotated vectors to within the match tolerance. 

522 fit_shift_rot_matrix = least_squares( 

523 _rotation_matrix_chi_sq, 

524 x0=shift_rot_matrix.flatten(), 

525 args=(src_pattern, ref_pattern, max_dist_rad) 

526 ).x.reshape((3, 3)) 

527 # Do another verify in case the fits have wondered off. 

528 if self._intermediate_verify_comparison( 

529 src_pattern, ref_pattern, fit_shift_rot_matrix, 

530 max_dist_rad): 

531 return fit_shift_rot_matrix 

532 

533 return None 

534 

535 def _create_spherical_rotation_matrix(self, rot_axis, cos_rotation, 

536 sin_rotion): 

537 """Construct a generalized 3D rotation matrix about a given 

538 axis. 

539 

540 Parameters 

541 ---------- 

542 rot_axis : `numpy.ndarray`, (3,) 

543 3 vector defining the axis to rotate about. 

544 cos_rotation : `float` 

545 cosine of the rotation angle. 

546 sin_rotation : `float` 

547 sine of the rotation angle. 

548 

549 Return 

550 ------ 

551 shift_matrix : `numpy.ndarray`, (3, 3) 

552 3x3 spherical, rotation matrix. 

553 """ 

554 rot_cross_matrix = np.array( 

555 [[0., -rot_axis[2], rot_axis[1]], 

556 [rot_axis[2], 0., -rot_axis[0]], 

557 [-rot_axis[1], rot_axis[0], 0.]], dtype=np.float64) 

558 shift_matrix = (cos_rotation*np.identity(3) 

559 + sin_rotion*rot_cross_matrix 

560 + (1. - cos_rotation)*np.outer(rot_axis, rot_axis)) 

561 

562 return shift_matrix 

563 

564 def _intermediate_verify_comparison(self, pattern_a, pattern_b, 

565 shift_rot_matrix, max_dist_rad): 

566 """Test the input rotation matrix against one input pattern and 

567 a second one. 

568 

569 If every point in the pattern after rotation is within a distance of 

570 max_dist_rad to its candidate point in the other pattern, we return 

571 True. 

572 

573 Parameters 

574 ---------- 

575 pattern_a : `numpy.ndarray`, (N,3) 

576 Array of 3 vectors representing the points that make up our source 

577 pinwheel pattern. 

578 pattern_b : `numpy.ndarray`, (N,3) 

579 Array of 3 vectors representing our candidate reference pinwheel 

580 pattern. 

581 shift_rot_matrix : `numpy.ndarray`, (3,3) 

582 3x3 rotation matrix that takes the source objects and rotates them 

583 onto the frame of the reference objects 

584 max_dist_rad : `float` 

585 Maximum distance allowed to consider two objects the same. 

586 

587 

588 Returns 

589 ------- 

590 bool 

591 True if all rotated source points are within max_dist_rad of 

592 the candidate references matches. 

593 """ 

594 shifted_pattern_a = np.dot(shift_rot_matrix, 

595 pattern_a.transpose()).transpose() 

596 tmp_delta_array = shifted_pattern_a - pattern_b 

597 tmp_dist_array = (tmp_delta_array[:, 0] ** 2 

598 + tmp_delta_array[:, 1] ** 2 

599 + tmp_delta_array[:, 2] ** 2) 

600 return np.all(tmp_dist_array < max_dist_rad ** 2) 

601 

602 def _test_pattern_lengths(self, test_pattern, max_dist_rad): 

603 """ Test that the all vectors in a pattern are unit length within 

604 tolerance. 

605 

606 This is useful for assuring the non unitary transforms do not contain 

607 too much distortion. 

608 

609 Parameters 

610 ---------- 

611 test_pattern : `numpy.ndarray`, (N, 3) 

612 Test vectors at the maximum and minimum x, y, z extents. 

613 max_dist_rad : `float` 

614 maximum distance in radians to consider two points "agreeing" on 

615 a rotation 

616 

617 Returns 

618 ------- 

619 test : `bool` 

620 Length tests pass. 

621 """ 

622 dists = (test_pattern[:, 0] ** 2 

623 + test_pattern[:, 1] ** 2 

624 + test_pattern[:, 2] ** 2) 

625 return np.all( 

626 np.logical_and((1 - max_dist_rad) ** 2 < dists, 

627 dists < (1 + max_dist_rad) ** 2)) 

628 

629 def _test_rotation_agreement(self, rot_vects, max_dist_rad): 

630 """ Test this rotation against the previous N found and return 

631 the number that a agree within tolerance to where our test 

632 points are. 

633 

634 Parameters 

635 ---------- 

636 rot_vects : `numpy.ndarray`, (N, 3) 

637 Arrays of rotated 3 vectors representing the maximum x, y, 

638 z extent on the unit sphere of the input source objects rotated by 

639 the candidate rotations into the reference frame. 

640 max_dist_rad : `float` 

641 maximum distance in radians to consider two points "agreeing" on 

642 a rotation 

643 

644 Returns 

645 ------- 

646 tot_consent : `int` 

647 Number of candidate rotations that agree for all of the rotated 

648 test 3 vectors. 

649 """ 

650 self.log.debug("Comparing pattern %i to previous %i rotations...", 

651 rot_vects[-1][-1], len(rot_vects) - 1) 

652 

653 tot_consent = 0 

654 for rot_idx in range(max((len(rot_vects) - 1), 0)): 

655 tmp_dist_list = [] 

656 for vect_idx in range(len(rot_vects[rot_idx]) - 1): 

657 tmp_delta_vect = (rot_vects[rot_idx][vect_idx] 

658 - rot_vects[-1][vect_idx]) 

659 tmp_dist_list.append( 

660 np.dot(tmp_delta_vect, tmp_delta_vect)) 

661 if np.all(np.array(tmp_dist_list) < max_dist_rad ** 2): 

662 tot_consent += 1 

663 return tot_consent 

664 

665 def _final_verify(self, 

666 source_array, 

667 shift_rot_matrix, 

668 max_dist_rad, 

669 min_matches): 

670 """Match the all sources into the reference catalog using the shift/rot 

671 matrix. 

672 

673 After the initial shift/rot matrix is found, we refit the shift/rot 

674 matrix using the matches the initial matrix produces to find a more 

675 stable solution. 

676 

677 Parameters 

678 ---------- 

679 source_array : `numpy.ndarray` (N, 3) 

680 3-vector positions on the unit-sphere representing the sources to 

681 match 

682 shift_rot_matrix : `numpy.ndarray` (3, 3) 

683 Rotation matrix representing inferred shift/rotation of the 

684 sources onto the reference catalog. Matrix need not be unitary. 

685 max_dist_rad : `float` 

686 Maximum distance allowed for a match. 

687 min_matches : `int` 

688 Minimum number of matched objects required to consider the 

689 match good. 

690 

691 Returns 

692 ------- 

693 output_struct : `lsst.pipe.base.Struct` 

694 Result struct with components: 

695 

696 - ``match_ids`` : Pairs of indexes into the source and reference 

697 data respectively defining a match (`numpy.ndarray`, (N, 2)). 

698 - ``distances_rad`` : distances to between the matched objects in 

699 the shift/rotated frame. (`numpy.ndarray`, (N,)). 

700 - ``max_dist_rad`` : Value of the max matched distance. Either 

701 returning the input value of the 2 sigma clipped value of the 

702 shift/rotated distances. (`float`). 

703 """ 

704 output_struct = pipeBase.Struct( 

705 match_ids=None, 

706 distances_rad=None, 

707 max_dist_rad=None, 

708 ) 

709 

710 # Perform an iterative final verify. 

711 match_sources_struct = self._match_sources(source_array, 

712 shift_rot_matrix) 

713 cut_ids = match_sources_struct.match_ids[ 

714 match_sources_struct.distances_rad < max_dist_rad] 

715 

716 n_matched = len(cut_ids) 

717 clipped_struct = self._clip_distances( 

718 match_sources_struct.distances_rad) 

719 n_matched_clipped = clipped_struct.n_matched_clipped 

720 

721 if n_matched < min_matches or n_matched_clipped < min_matches: 

722 return output_struct 

723 

724 # The shift/rotation matrix returned by 

725 # ``_construct_pattern_and_shift_rot_matrix``, above, was 

726 # based on only six points. Here, we refine that result by 

727 # using all of the good matches from the “final verification” 

728 # step, above. This will produce a more consistent result. 

729 fit_shift_rot_matrix = least_squares( 

730 _rotation_matrix_chi_sq, 

731 x0=shift_rot_matrix.flatten(), 

732 args=(source_array[cut_ids[:, 0], :3], 

733 self._reference_array[cut_ids[:, 1], :3], 

734 max_dist_rad) 

735 ).x.reshape((3, 3)) 

736 

737 # Redo the matching using the newly fit shift/rotation matrix. 

738 match_sources_struct = self._match_sources( 

739 source_array, fit_shift_rot_matrix) 

740 

741 # Double check the match distances to make sure enough matches 

742 # survive still. We'll just overwrite the previously used variables. 

743 n_matched = np.sum( 

744 match_sources_struct.distances_rad < max_dist_rad) 

745 clipped_struct = self._clip_distances( 

746 match_sources_struct.distances_rad) 

747 n_matched_clipped = clipped_struct.n_matched_clipped 

748 clipped_max_dist = clipped_struct.clipped_max_dist 

749 

750 if n_matched < min_matches or n_matched_clipped < min_matches: 

751 return output_struct 

752 

753 # Store our matches in the output struct. Decide between the clipped 

754 # distance and the input max dist based on which is smaller. 

755 output_struct.match_ids = match_sources_struct.match_ids 

756 output_struct.distances_rad = match_sources_struct.distances_rad 

757 if clipped_max_dist < max_dist_rad: 

758 output_struct.max_dist_rad = clipped_max_dist 

759 else: 

760 output_struct.max_dist_rad = max_dist_rad 

761 

762 return output_struct 

763 

764 def _clip_distances(self, distances_rad): 

765 """Compute a clipped max distance and calculate the number of pairs 

766 that pass the clipped dist. 

767 

768 Parameters 

769 ---------- 

770 distances_rad : `numpy.ndarray`, (N,) 

771 Distances between pairs. 

772 

773 Returns 

774 ------- 

775 output_struct : `lsst.pipe.base.Struct` 

776 Result struct with components: 

777 

778 - ``n_matched_clipped`` : Number of pairs that survive the 

779 clipping on distance. (`float`) 

780 - ``clipped_max_dist`` : Maximum distance after clipping. 

781 (`float`). 

782 """ 

783 clipped_dists, _, clipped_max_dist = sigmaclip( 

784 distances_rad, 

785 low=100, 

786 high=2) 

787 # Check clipped distances. The minimum value here 

788 # prevents over convergence on perfect test data. 

789 if clipped_max_dist < 1e-16: 

790 clipped_max_dist = 1e-16 

791 n_matched_clipped = np.sum(distances_rad < clipped_max_dist) 

792 else: 

793 n_matched_clipped = len(clipped_dists) 

794 

795 return pipeBase.Struct(n_matched_clipped=n_matched_clipped, 

796 clipped_max_dist=clipped_max_dist) 

797 

798 def _match_sources(self, 

799 source_array, 

800 shift_rot_matrix): 

801 """ Shift both the reference and source catalog to the the respective 

802 frames and find their nearest neighbor using a kdTree. 

803 

804 Removes all matches who do not agree when either the reference or 

805 source catalog is rotated. Cuts on a maximum distance are left to an 

806 external function. 

807 

808 Parameters 

809 ---------- 

810 source_array : `numpy.ndarray`, (N, 3) 

811 array of 3 vectors representing the source objects we are trying 

812 to match into the source catalog. 

813 shift_rot_matrix : `numpy.ndarray`, (3, 3) 

814 3x3 rotation matrix that performs the spherical rotation from the 

815 source frame into the reference frame. 

816 

817 Returns 

818 ------- 

819 results : `lsst.pipe.base.Struct` 

820 Result struct with components: 

821 

822 - ``matches`` : array of integer ids into the source and 

823 reference arrays. Matches are only returned for those that 

824 satisfy the distance and handshake criteria 

825 (`numpy.ndarray`, (N, 2)). 

826 - ``distances`` : Distances between each match in radians after 

827 the shift and rotation is applied (`numpy.ndarray`, (N)). 

828 """ 

829 shifted_references = np.dot( 

830 np.linalg.inv(shift_rot_matrix), 

831 self._reference_array.transpose()).transpose() 

832 shifted_sources = np.dot( 

833 shift_rot_matrix, 

834 source_array.transpose()).transpose() 

835 

836 ref_matches = np.empty((len(shifted_references), 2), 

837 dtype="uint16") 

838 src_matches = np.empty((len(shifted_sources), 2), 

839 dtype="uint16") 

840 

841 ref_matches[:, 1] = np.arange(len(shifted_references), 

842 dtype="uint16") 

843 src_matches[:, 0] = np.arange(len(shifted_sources), 

844 dtype="uint16") 

845 

846 ref_kdtree = cKDTree(self._reference_array) 

847 src_kdtree = cKDTree(source_array) 

848 

849 ref_to_src_dist, tmp_ref_to_src_idx = \ 

850 src_kdtree.query(shifted_references) 

851 src_to_ref_dist, tmp_src_to_ref_idx = \ 

852 ref_kdtree.query(shifted_sources) 

853 

854 ref_matches[:, 0] = tmp_ref_to_src_idx 

855 src_matches[:, 1] = tmp_src_to_ref_idx 

856 

857 handshake_mask = self._handshake_match(src_matches, ref_matches) 

858 return pipeBase.Struct( 

859 match_ids=src_matches[handshake_mask], 

860 distances_rad=src_to_ref_dist[handshake_mask],) 

861 

862 def _handshake_match(self, matches_src, matches_ref): 

863 """Return only those matches where both the source 

864 and reference objects agree they they are each others' 

865 nearest neighbor. 

866 

867 Parameters 

868 ---------- 

869 matches_src : `numpy.ndarray`, (N, 2) 

870 int array of nearest neighbor matches between shifted and 

871 rotated reference objects matched into the sources. 

872 matches_ref : `numpy.ndarray`, (N, 2) 

873 int array of nearest neighbor matches between shifted and 

874 rotated source objects matched into the references. 

875 

876 Return 

877 ------ 

878 handshake_mask_array : `numpy.ndarray`, (N,) 

879 Array positions where the two match catalogs agree. 

880 """ 

881 handshake_mask_array = np.zeros(len(matches_src), dtype=bool) 

882 

883 for src_match_idx, match in enumerate(matches_src): 

884 ref_match_idx = np.searchsorted(matches_ref[:, 1], match[1]) 

885 if match[0] == matches_ref[ref_match_idx, 0]: 

886 handshake_mask_array[src_match_idx] = True 

887 return handshake_mask_array