1+ import threading
12import warnings
23from functools import partial
34
1516
1617import numpy as np
1718import xarray as xr
18- from numba import cuda , prange
19+ from numba import cuda , jit , prange
1920
2021try :
2122 import cupy
@@ -493,33 +494,27 @@ def _is_target_value(v, target_values):
493494 return False
494495
495496
496- @ngjit
497- def _process_numpy_bruteforce (
498- img , xs , ys , target_values , max_distance , distance_metric , process_mode
499- ):
500- """Exact nearest-target proximity / allocation / direction on the CPU.
497+ # Numba parallel=True kernels must not be launched concurrently from multiple
498+ # Python threads: the default 'workqueue' threading layer is not threadsafe and
499+ # aborts the process (SIGABRT on macOS) when two host threads enter a parallel
500+ # region at once. _process_dask maps _process_numpy over chunks under dask's
501+ # threaded scheduler, and that reaches the brute-force kernel for GREAT_CIRCLE,
502+ # ALLOCATION, DIRECTION and the no-scipy PROXIMITY fallback, so the kernel
503+ # launch is serialized behind this lock. Same hazard and fix as the
504+ # convolution, terrain and reproject kernels (#3141).
505+ _PARALLEL_KERNEL_LOCK = threading .Lock ()
501506
502- For every pixel, scan all target pixels and keep the closest one under the
503- chosen distance metric. This is the same brute-force search the CUDA kernel
504- runs (see ``_proximity_cuda_kernel``). It covers what the cKDTree path
505- cannot: GREAT_CIRCLE (not a Minkowski metric), the tie-break-sensitive
506- ALLOCATION/DIRECTION modes, and PROXIMITY when scipy is missing.
507507
508- ``xs`` and ``ys`` are the per-pixel 2D coordinate grids built by the caller.
509- """
508+ @ngjit
509+ def _collect_targets (img , target_values ):
510+ """Row/col indices of every target pixel, in flat (row-major) order."""
510511 height , width = img .shape
511-
512- # Collect target pixel rows/cols in flat arrays (two passes: count, fill).
513512 n_targets = 0
514513 for line in range (height ):
515514 for col in range (width ):
516515 if _is_target_value (img [line , col ], target_values ):
517516 n_targets += 1
518517
519- output = np .full ((height , width ), np .nan , dtype = np .float32 )
520- if n_targets == 0 :
521- return output
522-
523518 target_rows = np .empty (n_targets , dtype = np .int64 )
524519 target_cols = np .empty (n_targets , dtype = np .int64 )
525520 t = 0
@@ -529,20 +524,165 @@ def _process_numpy_bruteforce(
529524 target_rows [t ] = line
530525 target_cols [t ] = col
531526 t += 1
527+ return target_rows , target_cols
528+
529+
530+ # The three inner loops below each scan every target for one pixel and return
531+ # (index, float32 distance) of the nearest one, or (-1, inf) with no targets.
532+ #
533+ # They compare a cheap proxy that is monotone in the distance (squared
534+ # distance, |dx| + |dy|, the haversine term) and only evaluate the sqrt /
535+ # arcsin and the float32 rounding when the proxy beats the running best. The
536+ # strict ``<`` that decides the winner still runs on the float32 distance, the
537+ # same value ``_distance`` returns, so the documented tie-break is unchanged:
538+ # two targets whose float64 distances differ only past the float32 mantissa
539+ # are a tie and the lowest flat index wins (issue #3689, and the matching
540+ # comment in ``_proximity_cuda_kernel``). A candidate whose proxy does not
541+ # beat the running best has a float32 distance >= the running best and could
542+ # never have won under that rule, so skipping it changes nothing.
543+
544+ @ngjit
545+ def _nearest_euclidean (px , py , txs , tys ):
546+ best_proxy = np .inf
547+ best_dist = np .float32 (np .inf )
548+ best_idx = - 1
549+ for k in range (len (txs )):
550+ dx = px - txs [k ]
551+ dy = py - tys [k ]
552+ proxy = dx * dx + dy * dy
553+ if proxy < best_proxy :
554+ d = np .float32 (np .sqrt (proxy ))
555+ better = d < best_dist
556+ best_idx = k if better else best_idx
557+ best_dist = d
558+ best_proxy = proxy
559+ return best_idx , best_dist
560+
561+
562+ @ngjit
563+ def _nearest_manhattan (px , py , txs , tys ):
564+ best_proxy = np .inf
565+ best_dist = np .float32 (np .inf )
566+ best_idx = - 1
567+ for k in range (len (txs )):
568+ dx = px - txs [k ]
569+ dy = py - tys [k ]
570+ proxy = abs (dx ) + abs (dy )
571+ if proxy < best_proxy :
572+ d = np .float32 (proxy )
573+ better = d < best_dist
574+ best_idx = k if better else best_idx
575+ best_dist = d
576+ best_proxy = proxy
577+ return best_idx , best_dist
578+
579+
580+ @ngjit
581+ def _nearest_great_circle (px , py , tlons , tlats , tcoslats ):
582+ # Same arithmetic, in the same order, as great_circle_distance with the
583+ # default radius, so the float32 distance is bit-identical to _distance.
584+ lat1 = np .radians (py )
585+ lon1 = np .radians (px )
586+ coslat1 = np .cos (lat1 )
587+ best_proxy = np .inf
588+ best_dist = np .float32 (np .inf )
589+ best_idx = - 1
590+ for k in range (len (tlons )):
591+ dlon = tlons [k ] - lon1
592+ dlat = tlats [k ] - lat1
593+ proxy = np .sin (dlat / 2.0 ) ** 2 + \
594+ coslat1 * tcoslats [k ] * np .sin (dlon / 2.0 ) ** 2
595+ if proxy < best_proxy :
596+ d = np .float32 (6378137 * 2 * np .arcsin (np .sqrt (proxy )))
597+ better = d < best_dist
598+ best_idx = k if better else best_idx
599+ best_dist = d
600+ best_proxy = proxy
601+ return best_idx , best_dist
602+
603+
604+ @ngjit
605+ def _great_circle_target_terms (txs , tys ):
606+ # Precompute the per-target radians and cos(lat) with numba's np.radians /
607+ # np.cos rather than numpy's, so they match what the per-pixel side of
608+ # _nearest_great_circle (and great_circle_distance) computes bit for bit.
609+ n = len (txs )
610+ tlons = np .empty (n , dtype = np .float64 )
611+ tlats = np .empty (n , dtype = np .float64 )
612+ tcoslats = np .empty (n , dtype = np .float64 )
613+ for k in range (n ):
614+ tlons [k ] = np .radians (txs [k ])
615+ tlats [k ] = np .radians (tys [k ])
616+ tcoslats [k ] = np .cos (tlats [k ])
617+ return tlons , tlats , tcoslats
618+
532619
620+ @ngjit
621+ def _great_circle_range_violation (xs , ys , txs , tys ):
622+ # Report the first out-of-range coordinate in the order the per-pair
623+ # guards in great_circle_distance would have hit it when the pixel loop
624+ # called it pair by pair: pixel (0, 0) against every target, then the
625+ # remaining pixels. 0 = no violation, otherwise the guard number (1: x of
626+ # the first point, 2: x of the second, 3: y of the first, 4: y of the
627+ # second). NaN coordinates (dask halo padding) fail no comparison, as
628+ # before.
629+ px = xs [0 , 0 ]
630+ py = ys [0 , 0 ]
631+ if px > 180 or px < - 180 :
632+ return 1
633+ if txs [0 ] > 180 or txs [0 ] < - 180 :
634+ return 2
635+ if py > 90 or py < - 90 :
636+ return 3
637+ if tys [0 ] > 90 or tys [0 ] < - 90 :
638+ return 4
639+ for k in range (1 , len (txs )):
640+ if txs [k ] > 180 or txs [k ] < - 180 :
641+ return 2
642+ if tys [k ] > 90 or tys [k ] < - 90 :
643+ return 4
644+ height , width = xs .shape
645+ for line in range (height ):
646+ for col in range (width ):
647+ if xs [line , col ] > 180 or xs [line , col ] < - 180 :
648+ return 1
649+ if ys [line , col ] > 90 or ys [line , col ] < - 90 :
650+ return 3
651+ return 0
652+
653+
654+ _GREAT_CIRCLE_RANGE_MESSAGES = {
655+ 1 : "Invalid x-coordinate of the first point."
656+ "Must be in the range [-180, 180]" ,
657+ 2 : "Invalid x-coordinate of the second point."
658+ "Must be in the range [-180, 180]" ,
659+ 3 : "Invalid y-coordinate of the first point."
660+ "Must be in the range [-90, 90]" ,
661+ 4 : "Invalid y-coordinate of the second point."
662+ "Must be in the range [-90, 90]" ,
663+ }
664+
665+
666+ @jit (nopython = True , nogil = True , parallel = True )
667+ def _bruteforce_kernel (
668+ img , xs , ys , target_rows , target_cols , txs , tys , tlons , tlats , tcoslats ,
669+ max_distance , distance_metric , process_mode , output
670+ ):
671+ # The metric branch sits per pixel, outside the target loop, and the
672+ # metric stays a runtime value so one compiled specialization serves all
673+ # three. Rows are independent, so prange over them.
674+ height , width = img .shape
533675 for line in prange (height ):
534676 for col in range (width ):
535677 px = xs [line , col ]
536678 py = ys [line , col ]
537- best_dist = np .float32 (np .inf )
538- best_idx = - 1
539- for k in range (n_targets ):
540- tx = xs [target_rows [k ], target_cols [k ]]
541- ty = ys [target_rows [k ], target_cols [k ]]
542- d = _distance (px , tx , py , ty , distance_metric )
543- if d < best_dist :
544- best_dist = d
545- best_idx = k
679+ if distance_metric == EUCLIDEAN :
680+ best_idx , best_dist = _nearest_euclidean (px , py , txs , tys )
681+ elif distance_metric == GREAT_CIRCLE :
682+ best_idx , best_dist = _nearest_great_circle (
683+ px , py , tlons , tlats , tcoslats )
684+ else :
685+ best_idx , best_dist = _nearest_manhattan (px , py , txs , tys )
546686 if best_idx >= 0 and best_dist <= max_distance :
547687 if process_mode == PROXIMITY :
548688 output [line , col ] = best_dist
@@ -551,8 +691,52 @@ def _process_numpy_bruteforce(
551691 target_rows [best_idx ], target_cols [best_idx ]]
552692 else :
553693 output [line , col ] = _calc_direction (
554- px , xs [target_rows [best_idx ], target_cols [best_idx ]],
555- py , ys [target_rows [best_idx ], target_cols [best_idx ]])
694+ px , txs [best_idx ], py , tys [best_idx ])
695+
696+
697+ def _process_numpy_bruteforce (
698+ img , xs , ys , target_values , max_distance , distance_metric , process_mode
699+ ):
700+ """Exact nearest-target proximity / allocation / direction on the CPU.
701+
702+ For every pixel, scan all target pixels and keep the closest one under the
703+ chosen distance metric. This is the same brute-force search the CUDA kernel
704+ runs (see ``_proximity_cuda_kernel``). It covers what the cKDTree path
705+ cannot: GREAT_CIRCLE (not a Minkowski metric), the tie-break-sensitive
706+ ALLOCATION/DIRECTION modes, and PROXIMITY when scipy is missing.
707+
708+ ``xs`` and ``ys`` are the per-pixel 2D coordinate grids built by the caller.
709+
710+ The target coordinates are gathered into flat arrays once, the per-metric
711+ inner loops live in ``_nearest_*`` and the pixel loop runs in parallel over
712+ rows in ``_bruteforce_kernel``, serialized behind ``_PARALLEL_KERNEL_LOCK``
713+ because the dask path calls this per chunk from worker threads.
714+ """
715+ target_rows , target_cols = _collect_targets (img , target_values )
716+
717+ output = np .full (img .shape , np .nan , dtype = np .float32 )
718+ if len (target_rows ) == 0 :
719+ return output
720+
721+ txs = xs [target_rows , target_cols ]
722+ tys = ys [target_rows , target_cols ]
723+
724+ if distance_metric == GREAT_CIRCLE :
725+ # The per-pair guards in great_circle_distance no longer run inside
726+ # the loop; check the grids once up front and raise the same message.
727+ violation = _great_circle_range_violation (xs , ys , txs , tys )
728+ if violation :
729+ raise ValueError (_GREAT_CIRCLE_RANGE_MESSAGES [violation ])
730+ tlons , tlats , tcoslats = _great_circle_target_terms (txs , tys )
731+ else :
732+ tlons = tlats = tcoslats = np .empty (0 , dtype = np .float64 )
733+
734+ with _PARALLEL_KERNEL_LOCK :
735+ _bruteforce_kernel (
736+ img , xs , ys , target_rows , target_cols , txs , tys ,
737+ tlons , tlats , tcoslats ,
738+ max_distance , distance_metric , process_mode , output ,
739+ )
556740 return output
557741
558742
0 commit comments