Coverage for src/dictk/grid.py: 98%

166 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-30 00:04 +0000

1"""A rectangular collection of points, and batch point tracking across it.""" 

2 

3import heapq 

4from collections.abc import Sequence 

5from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor 

6from enum import Enum 

7from functools import partial 

8from typing import Final 

9 

10import numpy as np 

11from scipy.spatial import cKDTree 

12 

13from dictk import translation, warp 

14from dictk.correlation import WindowingMethod 

15from dictk.image import PixelCoordinate, SubpixelCoordinate 

16 

17# How many nearest points `locate_warp_propagated` links each point to. 

18# On a rectangular grid, 8 covers the 4 edge neighbors and 4 diagonals. 

19NEIGHBOR_COUNT: Final[int] = 8 

20 

21# How many of the start search's best-scoring trials IC-GN refines. 

22START_SEED_COUNT: Final[int] = 3 

23 

24# How many start searches in a row may fail `znssd_limit` before 

25# `locate_warp_propagated` stops trying new start points. A 40x40 px 

26# patch of unrelated speckle over the image center needed more than 5. 

27START_ATTEMPT_LIMIT: Final[int] = 10 

28 

29 

30class Executor(Enum): 

31 """Pool type `locate`'s `max_workers` parameter runs on. 

32 

33 - THREAD: shares memory with the caller, no pickling. Pays a small 

34 scheduling cost on every task, which does not shrink as task count 

35 grows. 

36 - PROCESS: separate memory per worker, needs pickling. Pays a large 

37 fixed cost once, spawning workers and importing dependencies in 

38 each -- which then amortizes as task count grows. 

39 

40 See [Parallelization](../getting_started/parallelization.html) for 

41 measured trade-offs between the two. 

42 """ 

43 

44 THREAD = "thread" 

45 PROCESS = "process" 

46 

47 

48def _locate_worker( 

49 args: tuple[PixelCoordinate, PixelCoordinate], 

50 *, 

51 reference_image: np.ndarray, 

52 current_image: np.ndarray, 

53 kernel_margin_width: int, 

54 kernel_margin_height: int, 

55 search_margin_width: int, 

56 search_margin_height: int, 

57 windowing: WindowingMethod | None, 

58) -> PixelCoordinate: 

59 """One point's worth of `translation.locate`, as a single positional 

60 argument -- `Executor.map` (thread or process) always calls its 

61 target positionally, one item per iterable, so a keyword-only 

62 signature is not an option for the function being mapped over. 

63 Module-level on purpose: `ProcessPoolExecutor` needs a real, 

64 importable function to hand to spawned workers, not a closure. 

65 """ 

66 reference_point, search_center = args 

67 return translation.locate( 

68 reference_image=reference_image, 

69 current_image=current_image, 

70 reference_point=reference_point, 

71 search_center=search_center, 

72 kernel_margin_width=kernel_margin_width, 

73 kernel_margin_height=kernel_margin_height, 

74 search_margin_width=search_margin_width, 

75 search_margin_height=search_margin_height, 

76 windowing=windowing, 

77 ) 

78 

79 

80def _locate_subpixel_worker( 

81 args: tuple[PixelCoordinate, PixelCoordinate], 

82 *, 

83 reference_image: np.ndarray, 

84 current_image: np.ndarray, 

85 kernel_margin_width: int, 

86 kernel_margin_height: int, 

87 search_margin_width: int, 

88 search_margin_height: int, 

89 windowing: WindowingMethod | None, 

90 upsample_factor: int, 

91) -> SubpixelCoordinate: 

92 """One point's worth of `translation.locate_subpixel` -- see 

93 `_locate_worker`'s own docstring for why this is a module-level 

94 function taking a single positional argument, not a closure.""" 

95 reference_point, search_center = args 

96 return translation.locate_subpixel( 

97 reference_image=reference_image, 

98 current_image=current_image, 

99 reference_point=reference_point, 

100 search_center=search_center, 

101 kernel_margin_width=kernel_margin_width, 

102 kernel_margin_height=kernel_margin_height, 

103 search_margin_width=search_margin_width, 

104 search_margin_height=search_margin_height, 

105 windowing=windowing, 

106 upsample_factor=upsample_factor, 

107 ) 

108 

109 

110def _locate_warp_worker( 

111 args: tuple[PixelCoordinate, PixelCoordinate], 

112 *, 

113 reference_image: np.ndarray, 

114 current_image: np.ndarray, 

115 reference_gradients: tuple[np.ndarray, np.ndarray], 

116 current_coefficients: np.ndarray, 

117 kernel_margin_width: int, 

118 kernel_margin_height: int, 

119 search_margin_width: int, 

120 search_margin_height: int, 

121 windowing: WindowingMethod | None, 

122 max_iterations: int, 

123 tolerance: float, 

124) -> SubpixelCoordinate: 

125 """One point's worth of `warp.locate`, reusing gradients and spline 

126 coefficients computed once per batch -- see `_locate_worker`'s own 

127 docstring for why this is a module-level function taking a single 

128 positional argument, not a closure.""" 

129 reference_point, search_center = args 

130 start = translation.locate( 

131 reference_image=reference_image, 

132 current_image=current_image, 

133 reference_point=reference_point, 

134 search_center=search_center, 

135 kernel_margin_width=kernel_margin_width, 

136 kernel_margin_height=kernel_margin_height, 

137 search_margin_width=search_margin_width, 

138 search_margin_height=search_margin_height, 

139 windowing=windowing, 

140 ) 

141 fitted, _znssd, _iterations = warp._refine( 

142 reference_image=reference_image, 

143 reference_gradients=reference_gradients, 

144 current_coefficients=current_coefficients, 

145 reference_point=reference_point, 

146 initial_warp=warp._translation_warp( 

147 reference_point=reference_point, start=start 

148 ), 

149 kernel_margin_width=kernel_margin_width, 

150 kernel_margin_height=kernel_margin_height, 

151 max_iterations=max_iterations, 

152 tolerance=tolerance, 

153 ) 

154 return SubpixelCoordinate( 

155 x=reference_point.x + fitted[0, 2], y=reference_point.y + fitted[1, 2] 

156 ) 

157 

158 

159def generate( 

160 *, 

161 origin: PixelCoordinate, 

162 count_x: int, 

163 count_y: int, 

164 spacing_x: int, 

165 spacing_y: int, 

166) -> list[PixelCoordinate]: 

167 """Generate a rectangular collection of points spanning x and y. 

168 

169 Points are returned in row-major order (top-left to bottom-right: all 

170 of row 0 first, then row 1, and so on) -- point `i` sits at the same 

171 index a later batch tracking call (see `locate`) returns its found 

172 position at. 

173 

174 `count_x` and `count_y` need not be equal, and `spacing_x` and 

175 `spacing_y` need not be equal -- this is a general rectangular 

176 collection, not a square or uniformly-spaced one. 

177 

178 `count_x` or `count_y` of exactly `1` is allowed here -- a single 

179 row or column of points is still a meaningful point-tracking grid on 

180 its own. That's looser than [`elements`](#elements)'s own 

181 requirement of `>= 2` along each axis, since forming even 1 finite 

182 element needs 2 points per axis. The two functions check their own, 

183 different preconditions independently -- `generate` doesn't know or 

184 care about elements. 

185 

186 Args: 

187 origin: Position of the top-left point, in the source image's 

188 pixel reference frame. 

189 count_x: Number of points along x. Must be >= 1. 

190 count_y: Number of points along y. Must be >= 1. 

191 spacing_x: Pixel spacing between adjacent points along x. 

192 spacing_y: Pixel spacing between adjacent points along y. 

193 

194 Returns: 

195 A list of `count_x * count_y` `PixelCoordinate`s, in row-major 

196 order. 

197 

198 Raises: 

199 ValueError: If `count_x` or `count_y` is less than 1. 

200 """ 

201 if count_x < 1: 

202 raise ValueError(f"count_x {count_x} must be >= 1") 

203 if count_y < 1: 

204 raise ValueError(f"count_y {count_y} must be >= 1") 

205 

206 return [ 

207 PixelCoordinate(x=origin.x + i * spacing_x, y=origin.y + j * spacing_y) 

208 for j in range(count_y) 

209 for i in range(count_x) 

210 ] 

211 

212 

213def elements(*, count_x: int, count_y: int) -> list[tuple[int, int, int, int]]: 

214 """Q4 connectivity for the regular `count_x` x `count_y` lattice `generate` produces. 

215 

216 Each element is one of `generate`'s point grid's unit cells, its 4 

217 corner nodes given as indices into that same points list -- so 

218 `[points[i] for i in elements(...)[0]]` are one element's 4 corner 

219 `PixelCoordinate`s, ready to hand to 

220 [`dictk.element.gauss_point_green_lagrange_strains`](../api/dictk/element.html#gauss_point_green_lagrange_strains) 

221 or 

222 [`dictk.element.gauss_point_log_strains`](../api/dictk/element.html#gauss_point_log_strains). 

223 

224 Each 4-tuple is `(top_left, top_right, bottom_right, bottom_left)` 

225 point indices -- the same $N_1$..$N_4$ corner order those functions' 

226 `shape_functions` convention expects (see [Shape 

227 Functions](../getting_started/finite_element_method.html#shape-functions)). 

228 "Top"/"bottom" here use `generate`'s own image-pixel convention (y 

229 increasing downward, origin at the top-left point) -- not the 

230 math-style y-up convention `finite_element_method.md`'s figures use, 

231 where the same 4 points would be called `N1`..`N4`'s "bottom-left, 

232 bottom-right, top-right, top-left" instead. Only the labels differ 

233 between the two: the actual point order (start at one corner, then 

234 +x, then +x and +y together, then +y) is identical either way, and 

235 that -- not which direction is called "up" -- is what makes it match 

236 `shape_functions`' expected winding. 

237 

238 Args: 

239 count_x: Number of points along x in the source `generate` call. 

240 Must be >= 2 (at least 2 points make 1 element along x). 

241 count_y: Number of points along y in the source `generate` call. 

242 Must be >= 2. 

243 

244 Returns: 

245 A list of `(count_x - 1) * (count_y - 1)` 4-tuples, in row-major 

246 order (all of element row 0 first, then row 1, and so on) -- 

247 matching `generate`'s own point ordering. 

248 

249 Raises: 

250 ValueError: If `count_x` or `count_y` is less than 2. 

251 """ 

252 if count_x < 2: 

253 raise ValueError(f"count_x {count_x} must be >= 2") 

254 if count_y < 2: 

255 raise ValueError(f"count_y {count_y} must be >= 2") 

256 

257 return [ 

258 ( 

259 j * count_x + i, # top_left 

260 j * count_x + i + 1, # top_right 

261 (j + 1) * count_x + i + 1, # bottom_right 

262 (j + 1) * count_x + i, # bottom_left 

263 ) 

264 for j in range(count_y - 1) 

265 for i in range(count_x - 1) 

266 ] 

267 

268 

269def locate( 

270 *, 

271 reference_image: np.ndarray, 

272 current_image: np.ndarray, 

273 reference_points: Sequence[PixelCoordinate], 

274 search_centers: Sequence[PixelCoordinate] | None = None, 

275 kernel_margin_width: int, 

276 kernel_margin_height: int, 

277 search_margin_width: int, 

278 search_margin_height: int, 

279 windowing: WindowingMethod | None = None, 

280 max_workers: int | None = None, 

281 executor: Executor = Executor.THREAD, 

282) -> list[PixelCoordinate]: 

283 """Batch version of `dictk.translation.locate`: track many points at once. 

284 

285 Given a collection of `reference_points` (e.g. from `generate`), finds 

286 each one's position in `current_image` by calling 

287 [`dictk.translation.locate`](./translation.html#locate) once per point. 

288 Returns a flat list, index-aligned with `reference_points` -- the same 

289 row-major order `generate` produces, so point `i`'s found position is 

290 at index `i` of the result. 

291 

292 Args: 

293 reference_image: The reference (undeformed) 2D grayscale image. 

294 current_image: The current (deformed) 2D grayscale image. 

295 reference_points: Each point's fixed, known position, in 

296 `reference_image`'s pixel reference frame. 

297 search_centers: Where to center each point's search area, in 

298 `current_image`'s pixel reference frame -- a guess of roughly 

299 where each `reference_points` entry ended up. If `None` 

300 (default), each point's own `reference_points` entry is used 

301 as its own search center, the same reasonable default 

302 `translation.locate` documents for the single-point case. If 

303 given, must be the same length as `reference_points`. 

304 kernel_margin_width: Half each kernel's width, in pixels. Must be 

305 >= 1. 

306 kernel_margin_height: Half each kernel's height, in pixels. Must 

307 be >= 1. 

308 search_margin_width: Half each search area's width, in pixels. 

309 Must be greater than `kernel_margin_width`. 

310 search_margin_height: Half each search area's height, in pixels. 

311 Must be greater than `kernel_margin_height`. 

312 windowing: Passed straight through to each per-point 

313 [`dictk.translation.locate`](./translation.html#locate) call 

314 -- see its own `windowing` parameter. Default `None` applies 

315 no windowing to any point, matching this function's original 

316 behavior exactly. 

317 max_workers: If given, points are tracked concurrently across 

318 this many workers instead of one at a time. Default `None` 

319 stays sequential -- a plain loop, no pool, no overhead -- 

320 matching this function's original behavior exactly. See 

321 [Parallelization](../getting_started/parallelization.html) 

322 before setting this: at this book's own teaching scale 

323 (small kernels and search areas), sequential outperforms 

324 both pool types at any point count up to 1,000,000, measured. 

325 Concurrency only pays for itself once each point's own 

326 correlation is large enough, or point count is large enough 

327 to amortize `Executor.PROCESS`'s fixed startup cost -- see 

328 that page for the measured trade-offs. 

329 executor: Which pool type `max_workers` runs on. Ignored if 

330 `max_workers` is `None`. Default `Executor.THREAD`. 

331 

332 Returns: 

333 Each point's location, in `current_image`'s pixel reference 

334 frame, in the same order as `reference_points`. 

335 

336 Raises: 

337 ValueError: If `search_centers` is given and its length doesn't 

338 match `reference_points`, `max_workers` is given and less 

339 than 1, or (from the underlying per-point call) if the 

340 margin arguments are invalid. 

341 """ 

342 if search_centers is None: 

343 search_centers = reference_points 

344 elif len(search_centers) != len(reference_points): 

345 raise ValueError( 

346 f"search_centers length ({len(search_centers)}) must match " 

347 f"reference_points length ({len(reference_points)})" 

348 ) 

349 if max_workers is not None and max_workers < 1: 

350 raise ValueError(f"max_workers {max_workers} must be >= 1") 

351 

352 if max_workers is None: 

353 return [ 

354 translation.locate( 

355 reference_image=reference_image, 

356 current_image=current_image, 

357 reference_point=reference_point, 

358 search_center=search_center, 

359 kernel_margin_width=kernel_margin_width, 

360 kernel_margin_height=kernel_margin_height, 

361 search_margin_width=search_margin_width, 

362 search_margin_height=search_margin_height, 

363 windowing=windowing, 

364 ) 

365 for reference_point, search_center in zip(reference_points, search_centers) 

366 ] 

367 

368 worker = partial( 

369 _locate_worker, 

370 reference_image=reference_image, 

371 current_image=current_image, 

372 kernel_margin_width=kernel_margin_width, 

373 kernel_margin_height=kernel_margin_height, 

374 search_margin_width=search_margin_width, 

375 search_margin_height=search_margin_height, 

376 windowing=windowing, 

377 ) 

378 executor_cls = ( 

379 ThreadPoolExecutor if executor is Executor.THREAD else ProcessPoolExecutor 

380 ) 

381 with executor_cls(max_workers=max_workers) as pool: 

382 return list(pool.map(worker, zip(reference_points, search_centers))) 

383 

384 

385def locate_subpixel( 

386 *, 

387 reference_image: np.ndarray, 

388 current_image: np.ndarray, 

389 reference_points: Sequence[PixelCoordinate], 

390 search_centers: Sequence[PixelCoordinate] | None = None, 

391 kernel_margin_width: int, 

392 kernel_margin_height: int, 

393 search_margin_width: int, 

394 search_margin_height: int, 

395 windowing: WindowingMethod | None = None, 

396 upsample_factor: int = 100, 

397 max_workers: int | None = None, 

398 executor: Executor = Executor.THREAD, 

399) -> list[SubpixelCoordinate]: 

400 """Batch version of `dictk.translation.locate_subpixel`: track many 

401 points at once, with subpixel refinement. 

402 

403 Same structure as [`locate`](#locate) -- see its own docstring for 

404 the full explanation of `search_centers`, `windowing`, 

405 `max_workers`, and `executor`. The one behavioral difference beyond 

406 the added `upsample_factor` parameter: each point's found position 

407 is a [`SubpixelCoordinate`](../image.html#SubpixelCoordinate) 

408 (generally fractional), not a `PixelCoordinate`, matching 

409 `translation.locate_subpixel`'s own return type. 

410 

411 Args: 

412 reference_image: The reference (undeformed) 2D grayscale image. 

413 current_image: The current (deformed) 2D grayscale image. 

414 reference_points: Each point's fixed, known position, in 

415 `reference_image`'s pixel reference frame. 

416 search_centers: Where to center each point's search area -- see 

417 `locate`'s own docstring. If `None` (default), each point's 

418 own `reference_points` entry is used. 

419 kernel_margin_width: Half each kernel's width, in pixels. Must 

420 be >= 1. 

421 kernel_margin_height: Half each kernel's height, in pixels. Must 

422 be >= 1. 

423 search_margin_width: Half each search area's width, in pixels. 

424 Must be greater than `kernel_margin_width`. 

425 search_margin_height: Half each search area's height, in pixels. 

426 Must be greater than `kernel_margin_height`. 

427 windowing: Passed straight through to each per-point 

428 [`dictk.translation.locate_subpixel`](../translation.html#locate_subpixel) 

429 call. Default `None` applies no windowing. 

430 upsample_factor: Passed straight through to each per-point 

431 `locate_subpixel` call. Default `100`, matching that 

432 function's own default. Must be >= 1. 

433 max_workers: If given, points are tracked concurrently across 

434 this many workers instead of one at a time -- see `locate`'s 

435 own docstring for the measured trade-offs. 

436 executor: Which pool type `max_workers` runs on. Ignored if 

437 `max_workers` is `None`. Default `Executor.THREAD`. 

438 

439 Returns: 

440 Each point's location, in `current_image`'s pixel reference 

441 frame, generally fractional, in the same order as 

442 `reference_points`. 

443 

444 Raises: 

445 ValueError: If `search_centers` is given and its length doesn't 

446 match `reference_points`, `max_workers` is given and less 

447 than 1, or (from the underlying per-point call) if the 

448 margin or `upsample_factor` arguments are invalid. 

449 """ 

450 if search_centers is None: 

451 search_centers = reference_points 

452 elif len(search_centers) != len(reference_points): 

453 raise ValueError( 

454 f"search_centers length ({len(search_centers)}) must match " 

455 f"reference_points length ({len(reference_points)})" 

456 ) 

457 if max_workers is not None and max_workers < 1: 

458 raise ValueError(f"max_workers {max_workers} must be >= 1") 

459 

460 if max_workers is None: 

461 return [ 

462 translation.locate_subpixel( 

463 reference_image=reference_image, 

464 current_image=current_image, 

465 reference_point=reference_point, 

466 search_center=search_center, 

467 kernel_margin_width=kernel_margin_width, 

468 kernel_margin_height=kernel_margin_height, 

469 search_margin_width=search_margin_width, 

470 search_margin_height=search_margin_height, 

471 windowing=windowing, 

472 upsample_factor=upsample_factor, 

473 ) 

474 for reference_point, search_center in zip(reference_points, search_centers) 

475 ] 

476 

477 worker = partial( 

478 _locate_subpixel_worker, 

479 reference_image=reference_image, 

480 current_image=current_image, 

481 kernel_margin_width=kernel_margin_width, 

482 kernel_margin_height=kernel_margin_height, 

483 search_margin_width=search_margin_width, 

484 search_margin_height=search_margin_height, 

485 windowing=windowing, 

486 upsample_factor=upsample_factor, 

487 ) 

488 executor_cls = ( 

489 ThreadPoolExecutor if executor is Executor.THREAD else ProcessPoolExecutor 

490 ) 

491 with executor_cls(max_workers=max_workers) as pool: 

492 return list(pool.map(worker, zip(reference_points, search_centers))) 

493 

494 

495def locate_warp( 

496 *, 

497 reference_image: np.ndarray, 

498 current_image: np.ndarray, 

499 reference_points: Sequence[PixelCoordinate], 

500 search_centers: Sequence[PixelCoordinate] | None = None, 

501 kernel_margin_width: int, 

502 kernel_margin_height: int, 

503 search_margin_width: int, 

504 search_margin_height: int, 

505 windowing: WindowingMethod | None = None, 

506 max_iterations: int = 50, 

507 tolerance: float = 1e-6, 

508 max_workers: int | None = None, 

509 executor: Executor = Executor.THREAD, 

510) -> list[SubpixelCoordinate]: 

511 """Batch version of `dictk.warp.locate`: track many points at once 

512 by warping an affine kernel. 

513 

514 Same structure as [`locate_subpixel`](#locate_subpixel) -- see 

515 [`locate`](#locate)'s own docstring for the full explanation of 

516 `search_centers`, `windowing`, `max_workers`, and `executor`. 

517 `reference_image`'s gradients and `current_image`'s spline 

518 coefficients are computed once per call, then shared by every point. 

519 

520 Args: 

521 reference_image: The reference (undeformed) 2D grayscale image. 

522 current_image: The current (deformed) 2D grayscale image. 

523 reference_points: Each point's fixed, known position, in 

524 `reference_image`'s pixel reference frame. 

525 search_centers: Where to center each point's whole-pixel search 

526 -- see `locate`'s own docstring. If `None` (default), each 

527 point's own `reference_points` entry is used. 

528 kernel_margin_width: Half each kernel's width, in pixels, not 

529 counting its center column. Must be >= 1. 

530 kernel_margin_height: Half each kernel's height, in pixels, not 

531 counting its center row. Must be >= 1. 

532 search_margin_width: Half each whole-pixel search area's width, 

533 in pixels. Must be greater than `kernel_margin_width`. 

534 search_margin_height: Half each whole-pixel search area's 

535 height, in pixels. Must be greater than 

536 `kernel_margin_height`. 

537 windowing: Passed straight through to each point's whole-pixel 

538 stage. Default `None` applies no windowing. 

539 max_iterations: The most IC-GN updates per point. Default `50`. 

540 Must be >= 1. 

541 tolerance: IC-GN stops once every warp parameter's update falls 

542 below this. Default `1e-6`. Must be > 0. 

543 max_workers: If given, points are tracked concurrently across 

544 this many workers instead of one at a time. 

545 executor: Which pool type `max_workers` runs on. Ignored if 

546 `max_workers` is `None`. Default `Executor.THREAD`. 

547 

548 Returns: 

549 Each point's location, in `current_image`'s pixel reference 

550 frame, generally fractional, in the same order as 

551 `reference_points`. 

552 

553 Raises: 

554 ValueError: If `search_centers` is given but its length doesn't 

555 match `reference_points`, `max_workers` is less than 1, 

556 `max_iterations` is less than 1, `tolerance` is not 

557 positive, or any margin fails `dictk.translation.locate`'s 

558 own checks. 

559 """ 

560 if search_centers is None: 

561 search_centers = reference_points 

562 elif len(search_centers) != len(reference_points): 

563 raise ValueError( 

564 f"search_centers length ({len(search_centers)}) must match " 

565 f"reference_points length ({len(reference_points)})" 

566 ) 

567 if max_workers is not None and max_workers < 1: 

568 raise ValueError(f"max_workers {max_workers} must be >= 1") 

569 if max_iterations < 1: 

570 raise ValueError(f"max_iterations {max_iterations} must be >= 1") 

571 if tolerance <= 0: 

572 raise ValueError(f"tolerance {tolerance} must be > 0") 

573 

574 worker = partial( 

575 _locate_warp_worker, 

576 reference_image=reference_image, 

577 current_image=current_image, 

578 reference_gradients=warp._gradients(reference_image), 

579 current_coefficients=warp._spline_coefficients(current_image), 

580 kernel_margin_width=kernel_margin_width, 

581 kernel_margin_height=kernel_margin_height, 

582 search_margin_width=search_margin_width, 

583 search_margin_height=search_margin_height, 

584 windowing=windowing, 

585 max_iterations=max_iterations, 

586 tolerance=tolerance, 

587 ) 

588 if max_workers is None: 

589 return [worker(pair) for pair in zip(reference_points, search_centers)] 

590 

591 executor_cls = ( 

592 ThreadPoolExecutor if executor is Executor.THREAD else ProcessPoolExecutor 

593 ) 

594 with executor_cls(max_workers=max_workers) as pool: 

595 return list(pool.map(worker, zip(reference_points, search_centers))) 

596 

597 

598def locate_warp_propagated( 

599 *, 

600 reference_image: np.ndarray, 

601 current_image: np.ndarray, 

602 reference_points: Sequence[PixelCoordinate], 

603 kernel_margin_width: int, 

604 kernel_margin_height: int, 

605 search_margin_width: int, 

606 search_margin_height: int, 

607 start_gradient_limit: float = 0.3, 

608 start_gradient_step: float = 0.1, 

609 znssd_limit: float = 0.3, 

610 max_iterations: int = 50, 

611 tolerance: float = 1e-6, 

612) -> tuple[list[SubpixelCoordinate], np.ndarray]: 

613 """Track many points by warping an affine kernel, seeding each point 

614 from an already-fitted neighbor instead of a rigid whole-pixel match. 

615 

616 [`locate_warp`](#locate_warp) seeds every point's IC-GN fit from 

617 `dictk.translation.locate`'s rigid, whole-pixel match. Under a large 

618 stretch or shear, that match can land on the wrong speckles. 

619 

620 This function searches for a deformed seed at one point only: the 

621 point nearest the center of `reference_image`. It tries every 

622 displacement gradient `(u_x, u_y, v_x, v_y)` on a grid from 

623 `-start_gradient_limit` to `start_gradient_limit` in steps of 

624 `start_gradient_step`. For each one, it deforms the reference 

625 kernel and finds its whole-pixel translation by phase correlation. 

626 IC-GN refines the best-scoring trials, and the fit with the lowest 

627 ZNSSD becomes the first point's warp. 

628 

629 A fit is accepted when its ZNSSD is at most `znssd_limit`. Only 

630 accepted fits seed other points. The remaining points are visited 

631 best first: the accepted point with the lowest ZNSSD seeds each of 

632 its `NEIGHBOR_COUNT` nearest points not yet accepted. Each starts 

633 from that neighbor's warp: the same displacement gradients, and a 

634 translation extrapolated across the offset between the two points. 

635 A point that fails `znssd_limit` tries again when another accepted 

636 neighbor reaches it. See [Seed 

637 Propagation](../getting_started/seed_propagation.html). 

638 

639 If the first point's fit fails `znssd_limit`, the start search 

640 moves to the next untried point nearest the image center, skipping 

641 points within one kernel width of a failed start, since those share 

642 most of its kernel. Points 

643 no accepted fit can reach get their own start search the same way. 

644 The search stops after `START_ATTEMPT_LIMIT` failed starts in a 

645 row. Start points whose search area leaves `current_image` are 

646 skipped. 

647 

648 Only start points use the search area, centered on each one's own 

649 reference position. Every other point skips the 

650 whole-pixel search, so the search margins do not limit how far it 

651 can move. The fit runs one point at a time, because each seed 

652 depends on an earlier fit. 

653 

654 Args: 

655 reference_image: The reference (undeformed) 2D grayscale image. 

656 current_image: The current (deformed) 2D grayscale image. 

657 reference_points: Each point's fixed, known position, in 

658 `reference_image`'s pixel reference frame. Each point's 

659 kernel must lie inside `reference_image`. 

660 kernel_margin_width: Half each kernel's width, in pixels, not 

661 counting its center column. Must be >= 1. 

662 kernel_margin_height: Half each kernel's height, in pixels, not 

663 counting its center row. Must be >= 1. 

664 search_margin_width: Half the whole-pixel search area's width, 

665 in pixels, for start points. Must be greater than 

666 `kernel_margin_width`. 

667 search_margin_height: Half the whole-pixel search area's 

668 height, in pixels, for start points. Must be greater than 

669 `kernel_margin_height`. 

670 start_gradient_limit: The largest displacement gradient the 

671 start search tries, in each of its four terms. Default 

672 `0.3`. Must be > 0. 

673 start_gradient_step: The spacing between tried gradients. 

674 Default `0.1`, which tries 7 values per term and 2401 in 

675 all. Must be > 0. 

676 znssd_limit: The largest ZNSSD a fit may have and still seed 

677 other points. Default `0.3`. Must be > 0. 

678 max_iterations: The most IC-GN updates per point. Default `50`. 

679 Must be >= 1. 

680 tolerance: IC-GN stops once every warp parameter's update falls 

681 below this. Default `1e-6`. Must be > 0. 

682 

683 Returns: 

684 Two items, each in the same order as `reference_points`: 

685 

686 - Each point's location, in `current_image`'s pixel reference 

687 frame, generally fractional. It comes from the point's 

688 lowest-ZNSSD fit, even when that fit failed `znssd_limit`. 

689 A point never fitted at all gets `nan` for `x` and `y`. 

690 - Each point's ZNSSD for that fit, from 0 for a perfect match 

691 to 4, or `inf` for a point never fitted. Values above 

692 `znssd_limit` mark positions not to trust. 

693 

694 Raises: 

695 ValueError: If `reference_points` is empty, any point's kernel 

696 leaves `reference_image`, a margin is too small, 

697 `start_gradient_limit`, `start_gradient_step`, or 

698 `znssd_limit` is not positive, `max_iterations` is less 

699 than 1, or `tolerance` is not positive. 

700 """ 

701 if len(reference_points) == 0: 

702 raise ValueError("reference_points must not be empty") 

703 if kernel_margin_width < 1: 

704 raise ValueError(f"kernel_margin_width {kernel_margin_width} must be >= 1") 

705 if kernel_margin_height < 1: 

706 raise ValueError(f"kernel_margin_height {kernel_margin_height} must be >= 1") 

707 if search_margin_width <= kernel_margin_width: 

708 raise ValueError( 

709 f"search_margin_width {search_margin_width} must be greater than " 

710 f"kernel_margin_width {kernel_margin_width}" 

711 ) 

712 if search_margin_height <= kernel_margin_height: 

713 raise ValueError( 

714 f"search_margin_height {search_margin_height} must be greater than " 

715 f"kernel_margin_height {kernel_margin_height}" 

716 ) 

717 if start_gradient_limit <= 0: 

718 raise ValueError(f"start_gradient_limit {start_gradient_limit} must be > 0") 

719 if start_gradient_step <= 0: 

720 raise ValueError(f"start_gradient_step {start_gradient_step} must be > 0") 

721 if znssd_limit <= 0: 

722 raise ValueError(f"znssd_limit {znssd_limit} must be > 0") 

723 if max_iterations < 1: 

724 raise ValueError(f"max_iterations {max_iterations} must be >= 1") 

725 if tolerance <= 0: 

726 raise ValueError(f"tolerance {tolerance} must be > 0") 

727 height, width = reference_image.shape 

728 for point in reference_points: 

729 if not ( 

730 kernel_margin_width <= point.x < width - kernel_margin_width 

731 and kernel_margin_height <= point.y < height - kernel_margin_height 

732 ): 

733 raise ValueError(f"kernel around {point} leaves reference_image") 

734 

735 positions = np.array([[p.x, p.y] for p in reference_points], dtype=np.float64) 

736 neighbor_count = min(NEIGHBOR_COUNT + 1, len(reference_points)) 

737 _, neighbors = cKDTree(positions).query(positions, k=neighbor_count) 

738 neighbors = np.asarray(neighbors).reshape(len(reference_points), -1) 

739 center = np.array([(width - 1) / 2, (height - 1) / 2]) 

740 by_distance = np.argsort(np.linalg.norm(positions - center, axis=1)) 

741 

742 reference_gradients = warp._gradients(reference_image) 

743 reference_coefficients = warp._spline_coefficients(reference_image) 

744 current_coefficients = warp._spline_coefficients(current_image) 

745 

746 def refined(index, initial_warp): 

747 return warp._refine( 

748 reference_image=reference_image, 

749 reference_gradients=reference_gradients, 

750 current_coefficients=current_coefficients, 

751 reference_point=reference_points[index], 

752 initial_warp=initial_warp, 

753 kernel_margin_width=kernel_margin_width, 

754 kernel_margin_height=kernel_margin_height, 

755 max_iterations=max_iterations, 

756 tolerance=tolerance, 

757 ) 

758 

759 count = len(reference_points) 

760 warps: list[np.ndarray | None] = [None] * count 

761 znssds = np.full(count, np.inf) 

762 accepted = np.zeros(count, dtype=bool) 

763 tried = np.zeros(count, dtype=bool) 

764 current_height, current_width = current_image.shape 

765 

766 def record(index, fit): 

767 fitted, znssd, _iterations = fit 

768 tried[index] = True 

769 if znssd < znssds[index]: 

770 warps[index], znssds[index] = fitted, znssd 

771 if znssd <= znssd_limit: 

772 accepted[index] = True 

773 return accepted[index] 

774 

775 def propagate(first): 

776 heap = [(znssds[first], first)] 

777 while heap: 

778 _, parent = heapq.heappop(heap) 

779 parent_warp = warps[parent] 

780 for child in neighbors[parent]: 

781 if accepted[child]: 

782 continue 

783 offset = positions[child] - positions[parent] 

784 initial_warp = parent_warp.copy() 

785 initial_warp[:2, 2] += (parent_warp[:2, :2] - np.eye(2)) @ offset 

786 if record(child, refined(child, initial_warp)): 

787 heapq.heappush(heap, (znssds[child], child)) 

788 

789 failed_starts = 0 

790 failed_start_positions: list[np.ndarray] = [] 

791 kernel_width = 2 * max(kernel_margin_width, kernel_margin_height) + 1 

792 for first in by_distance: 

793 if tried[first] or failed_starts >= START_ATTEMPT_LIMIT: 

794 continue 

795 if any( 

796 np.linalg.norm(positions[first] - failed) < kernel_width 

797 for failed in failed_start_positions 

798 ): 

799 continue 

800 point = reference_points[first] 

801 if not ( 

802 search_margin_width <= point.x <= current_width - search_margin_width 

803 and search_margin_height <= point.y <= current_height - search_margin_height 

804 ): 

805 continue 

806 seeds = warp._shape_seeds( 

807 reference_coefficients=reference_coefficients, 

808 current_image=current_image, 

809 current_coefficients=current_coefficients, 

810 reference_point=point, 

811 kernel_margin_width=kernel_margin_width, 

812 kernel_margin_height=kernel_margin_height, 

813 search_margin_width=search_margin_width, 

814 search_margin_height=search_margin_height, 

815 gradient_limit=start_gradient_limit, 

816 gradient_step=start_gradient_step, 

817 seed_count=START_SEED_COUNT, 

818 ) 

819 best = min((refined(first, seed) for seed in seeds), key=lambda fit: fit[1]) 

820 if record(first, best): 

821 failed_starts = 0 

822 propagate(first) 

823 else: 

824 failed_starts += 1 

825 failed_start_positions.append(positions[first]) 

826 

827 positions_found = [ 

828 SubpixelCoordinate(x=p.x + w[0, 2], y=p.y + w[1, 2]) 

829 if w is not None 

830 else SubpixelCoordinate(x=np.nan, y=np.nan) 

831 for p, w in zip(reference_points, warps) 

832 ] 

833 return positions_found, znssds