Эффективный поиск по окрестностям в numpy ndarray вместо вложенных условных циклов for

Хотя есть много случаев вопроса: что такое пустая альтернатива вложенным циклам for, я не смог найти подходящий ответ для своего случая. Вот оно:

У меня есть массив 3D numpy с фоном 0 и другими целыми числами в качестве переднего плана. Я хотел бы найти и сохранить воксели переднего плана, которые попадают в предопределенную маску (сфера, определяющая заданное расстояние от эталонного узла). Я успешно выполнил задачу, используя вложенные циклы for и цепочку условий if, как показано ниже. Я ищу более эффективную и компактную альтернативу, чтобы избежать циклов и длинных условий для этого алгоритма поиска окрестности.

пример входных данных:

import numpy as np

im = np.array([[[ 60, 54, 47, 52, 57, 53, 46, 48]
, [ 60, 57, 53, 53, 54, 53, 50, 55]
, [ 60, 63, 56, 58, 59, 57, 50, 50]
, [ 70, 70, 64, 69, 74, 72, 64, 47]
, [ 73, 76, 77, 80, 82, 76, 58, 37]
, [ 85, 85, 86, 86, 78, 62, 38, 20]
, [ 94, 94, 92, 78, 54, 33, 16, 255]
, [ 94, 90, 72, 51, 32, 19, 255, 255]
, [ 65, 53, 29, 18, 255, 255, 255, 255]
, [ 29, 22, 255, 255, 255, 255, 255,  0]]

, [[ 66, 67, 70, 69, 75, 73, 72, 63]
, [ 68, 70, 73, 74, 78, 80, 74, 53]
, [ 75, 87, 87, 83, 89, 86, 61, 33]
, [ 81, 89, 88, 98, 99, 77, 41, 18]
, [ 84, 94, 100, 100, 82, 49, 21, 255]
, [ 99, 101, 92, 75, 48, 25, 255, 255]
, [ 93, 77, 52, 32, 255, 255, 255, 255]
, [ 52, 40, 25, 255, 255, 255, 255, 255]
, [ 23, 16, 255, 255, 255, 255, 255,  0]
, [255, 255, 255, 255, 255, 255,  0,  0]]

, [[ 81, 83, 92, 101, 101, 83, 49, 19]
, [ 86, 96, 103, 103, 95, 64, 28, 255]
, [ 94, 103, 107, 98, 79, 41, 255, 255]
, [101, 103, 98, 79, 51, 28, 255, 255]
, [102, 97, 76, 49, 27, 255, 255, 255]
, [ 79, 62, 35, 21, 255, 255, 255, 255]
, [ 33, 23, 15, 255, 255, 255, 255, 255]
, [ 16, 255, 255, 255, 255, 255, 255,  0]
, [255, 255, 255, 255, 255, 255,  0,  0]
, [255, 255, 255, 255, 255,  0,  0,  0]]

, [[106, 107, 109, 94, 58, 26, 15, 255]
, [110, 104, 90, 66, 37, 19, 255, 255]
, [106, 89, 61, 35, 22, 255, 255, 255]
, [ 76, 56, 34, 19, 255, 255, 255, 255]
, [ 40, 27, 18, 255, 255, 255, 255, 255]
, [ 17, 255, 255, 255, 255, 255, 255, 255]
, [255, 255, 255, 255, 255, 255, 255,  0]
, [255, 255, 255, 255, 255, 255,  0,  0]
, [255, 255, 255, 255, 255,  0,  0,  0]
, [255, 255, 255,  0,  0,  0,  0,  0]]

, [[ 68, 51, 33, 19, 255, 255, 255, 255]
, [ 45, 34, 20, 255, 255, 255, 255, 255]
, [ 28, 18, 255, 255, 255, 255, 255, 255]
, [ 17, 255, 255, 255, 255, 255, 255, 255]
, [255, 255, 255, 255, 255, 255, 255, 255]
, [255, 255, 255, 255, 255, 255, 255,  0]
, [255, 255, 255, 255, 255, 255,  0,  0]
, [255, 255, 255, 255, 255,  0,  0,  0]
, [255, 255, 255,  0,  0,  0,  0,  0]
, [255,  0,  0,  0,  0,  0,  0,  0]]

, [[255, 255, 255, 255, 255, 255, 255, 255]
, [255, 255, 255, 255, 255, 255, 255, 255]
, [255, 255, 255, 255, 255, 255, 255, 255]
, [255, 255, 255, 255, 255, 255, 255,  0]
, [255, 255, 255, 255, 255, 255,  0,  0]
, [255, 255, 255, 255, 255,  0,  0,  0]
, [255, 255, 255, 255,  0,  0,  0,  0]
, [255, 255, 255,  0,  0,  0,  0,  0]
, [255,  0,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]]

, [[255, 255, 255, 255, 255, 255, 255,  0]
, [255, 255, 255, 255, 255, 255, 255,  0]
, [255, 255, 255, 255, 255, 255,  0,  0]
, [255, 255, 255, 255, 255,  0,  0,  0]
, [255, 255, 255, 255,  0,  0,  0,  0]
, [255, 255, 255,  0,  0,  0,  0,  0]
, [255, 255,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]]

, [[255, 255, 255, 255, 255, 255,  0,  0]
, [255, 255, 255, 255, 255,  0,  0,  0]
, [255, 255, 255, 255,  0,  0,  0,  0]
, [255, 255, 255,  0,  0,  0,  0,  0]
, [255, 255,  0,  0,  0,  0,  0,  0]
, [255,  0,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]
, [  0,  0,  0,  0,  0,  0,  0,  0]]])

Реализованный метод:

[Z,Y,X]=im.shape
RN = np.array([3,4,4])     
################Loading Area search
rad = 3
a,b,c = RN
x,y,z = np.ogrid[-c:Z-c,-b:Y-b,-a:X-a]
neighborMask = x*x + y*y + z*z<= rad*rad
noNodeMask = im > 0
mask = np.logical_and(neighborMask, noNodeMask)

imtemp = im.copy()
imtemp[mask] = -1

for i in range (X):
    for j in range (Y):
        for k in range (Z):
            if imtemp[i,j,k]==-1:
                if i in (0, X-1) or j in (0, Y-1) or k in (0, Z-1): 
                    imtemp[i,j,k]=-2
                elif imtemp[i+1,j,k] == 0 or imtemp[i-1,j,k] == 0 or imtemp[i,j+1,k] == 0 or imtemp[i,j-1,k] == 0 or imtemp[i,j,k+1] == 0 or imtemp[i,j,k-1] == 0:
                    imtemp[i,j,k]=-2
                    
LA = np.argwhere(imtemp==-2)        

Результирующий LA из приведенного выше примера кода:

In [90]:LA
Out[90]: 
array([[4, 4, 0],
       [4, 4, 6],
       [4, 5, 5],
       [4, 6, 4],
       [4, 6, 5],
       [4, 7, 3],
       [5, 3, 5],
       [5, 4, 4],
       [5, 4, 5],
       [5, 5, 3],
       [5, 5, 4],
       [5, 6, 2],
       [5, 6, 3],
       [6, 2, 4],
       [6, 3, 3],
       [6, 3, 4],
       [6, 4, 2],
       [6, 4, 3],
       [6, 5, 1],
       [6, 5, 2]])

И срез в направлении Z (экземпляр плоскости XY), который показывает разные нетронутые, замаскированные (-1) и целевые (-2) узлы: образец фрагмента исходной и замаскированной матриц


person Silent    schedule 11.08.2020    source источник
comment
Вы хотели сделать внутренние диапазоны от 1 до X/Y - 1? В первой итерации (i=0) imtemp[i-1, j, k] не терпит неудачу, но это последний элемент на первой оси...   -  person Mad Physicist    schedule 11.08.2020
comment
Мне пришлось изменить порядок из-за того, что массив im читался из изображения MHD. Но диапазоны в данном примере в порядке. Я не совсем уверен, что вы подразумеваете под 1 до X/Y.   -  person Silent    schedule 12.08.2020
comment
Этот цикл выглядит неправильно: for j in range (Y-1):. Как и тот, что внутри него   -  person Mad Physicist    schedule 12.08.2020
comment
Кроме того, if i==(Z-1): создает большую плоскую карту -2 внизу изображения, за пределами сферы.   -  person Mad Physicist    schedule 12.08.2020
comment
Можете ли вы концептуально объяснить, как вы хотите сделать оболочку?   -  person Mad Physicist    schedule 12.08.2020
comment
@MadPhysicist Спасибо за предложение помощи ниже. Итак, я скомпилировал код с примерами данных для im в своей системе, и мне кажется, что он работает правильно. Однако, поскольку размеры X и Z здесь на самом деле равны, во избежание путаницы (обратные шнуры из-за МГД-ридера, и я с ним разобрался), я отредактировал индексы петель. Я также включил результат LA и снимок среза, показывающий правильные узлы оболочки. для концептуального объяснения прочитайте следующий комментарий:   -  person Silent    schedule 13.08.2020
comment
Представьте себе шар внутри куба. узлы шара имеют значения ›0, где остальная часть куба равна 0. Я хотел бы найти расположение узлов на поверхности шара, которые попадают в меньшую сферу, которая пересекается с шаром.   -  person Silent    schedule 13.08.2020
comment
Итак, если куб пересекает шар, вы не хотите, чтобы стороны куба были частью шара? Потому что некоторые стороны в настоящее время находятся в вашем цикле, а другие непреднамеренно зацикливаются.   -  person Mad Physicist    schedule 13.08.2020
comment
@MadPhysicist Я хочу включить стороны, которые пересекаются с шаром И находятся внутри маски. В текущем коде я включил это условие только для верхней стороны куба, прежде чем переходить к поиску окрестности. Однако я должен исправить это, чтобы включить все стороны.   -  person Silent    schedule 13.08.2020
comment
Мог бы ты это сделать? Я хочу воспроизвести точный результат, который вы получили, и я почти уверен, что то, что у вас есть сейчас, непоследовательно / неправильно.   -  person Mad Physicist    schedule 13.08.2020
comment
@MadPhysicist Готово!   -  person Silent    schedule 13.08.2020
comment
Я отправил ответ, но заметил, что у вас есть еще одна ошибка, вызванная тем, что иногда вы индексируете размеры вперед, иногда назад. Пределы ваших циклов задом наперед. Это должно быть либо for i in range(Z), либо X, Y, Z = im.shape. Единственная причина, по которой это не падает, заключается в том, что X==Z по стечению обстоятельств.   -  person Mad Physicist    schedule 14.08.2020
comment
При этом я точно воспроизвожу ваши результаты. Мне потребовалось некоторое время, чтобы понять, что вы делаете ... <= rad*rad, а я использую < :)   -  person Mad Physicist    schedule 14.08.2020


Ответы (2)


Постановка задачи

У вас есть твердая форма в большом массиве. Из него вырезаешь мяч. Ваша цель — найти индексы поверхности твердого тела внутри шара. Поверхности определяются как любая точка, примыкающая к внешней стороне твердого тела с 6-точечной связностью. Ребра массива также считаются поверхностями.

Решение для более быстрого цикла

Вы уже вычислили маску, представляющую пересечение твердого тела и шара. Вы можете вычислить маску немного более элегантно и вместо этого преобразовать ее в индексы. Я предлагаю сохранить порядок ваших размеров постоянным, вместо того, чтобы переключаться между различными обозначениями. Например, это влияет на порядок RN, и вы рискуете не соответствовать ограничениям оси.

RN = np.array([4, 4, 3])
rad = 3

im = ...

cutout = ((np.indices(im.shape) - RN.reshape(-1, 1, 1, 1))**2).sum(axis=0) <= rad**2
solid = im > 0
mask = solid & cutout
indices = np.argwhere(mask)

Вы также можете получить вырез без изменения формы RN, выполнив

cutout = ((np.rollaxis(np.indices(im.shape, sparse=False), 0, 4) - RN)**2).sum(axis=-1) <= rad**2

Хорошая вещь в вычислении индексов заключается в том, что ваши циклы больше не должны быть огромными. Используя argwhere, вы в основном удаляете три внешних цикла, оставляя только оператор if для цикла. Вы также можете векторизовать проверку подключения. У этого есть приятный побочный эффект: вы можете определить произвольное соединение для каждого пикселя.

limit = np.array(im.shape) - 1  # Edge of `im`
connectivity = np.array([[ 1,  0,  0],  # Add rows to determine connectivity
                         [-1,  0,  0],
                         [ 0,  1,  0],
                         [ 0, -1,  0],
                         [ 0,  0,  1],
                         [ 0,  0, -1]], dtype=indices.dtype)
index_mask = np.ones(len(indices), dtype=bool)

for n, ind in enumerate(indices):
    if ind.all() and (ind < limit).all() and im[tuple((ind + connectivity).T)].all():
        index_mask[n] = False

LA = indices[index_mask, :]

Обратите внимание, что в imtemp нет никакого смысла. Даже в исходном цикле вы могли просто манипулировать mask напрямую. Вместо того, чтобы устанавливать для элементов значение -2, когда они соответствуют вашему критерию, вы можете установить для элементов значение False, если они этого не сделали.

Я делаю что-то подобное здесь. Мы проверяем каждый из фактически выбранных индексов и определяем, находится ли какой-либо из них внутри тела. Эти индексы исключаются из маски. Затем список индексов обновляется на основе маски.

Проверка ind.all() and (ind < limit).all() and im[tuple((ind + connectivity).T)].all() — это сокращение того, что вы делали с условиями or, но в обратном порядке (тестирование не на поверхности, а на поверхности).

  • ind.all() проверяет, что ни один из индексов не равен нулю: т. е. не на верхней/передней/левой поверхности.
  • (ind < limit).all() проверяет, что ни один из индексов не равен соответствующему размеру изображения минус один.
  • im[tuple((ind + connectivity).T)].all() проверяет, что ни один из подключенных пикселей не равен нулю. (ind + connectivity).T — это массив (3, 6) из шести точек, к которым мы подключены (в настоящее время определяется как +/-1 по каждой оси массивом (6, 3) connectivity). Когда вы превращаете его в кортеж, он просто становится причудливым индексом, как если бы вы сделали что-то вроде im[x + connectivity[:, 0], y + connectivity[:, 1], z + connectivity[:, 2]]. Запятые в индексе просто превращают его в кортеж. Способ, который я показываю, лучше подходит для произвольного количества измерений.

Пиксели, прошедшие все три теста, находятся внутри твердого тела и удаляются. Конечно, вы можете написать цикл для проверки по-другому, но тогда вам придется изменить свою маску:

index_mask = np.zeros(len(indices), dtype=bool)

for n, ind in enumerate(indices):
    if (ind == 0).any() or (ind == limit).any() or (im[tuple((ind + connectivity).T)] == 0).any():
        index_mask[n] = True

LA = indices[index_mask, :]

Зацикливание вручную ни в коем случае не является идеальным. Тем не менее, он показывает вам, как сократить цикл (вероятно, на пару порядков) и как определить произвольное соединение с помощью векторизации и широковещания, не увязая в жестком кодировании.

Полностью векторизованное решение

Циклы выше можно полностью векторизовать, используя магию вещания. Вместо того, чтобы перебирать каждую строку в indices, мы можем добавить к ней connectivity массово и фильтровать результаты массово. Хитрость заключается в том, чтобы добавить достаточно измерений, чтобы добавить все connectivity к каждому элементу indices.

Вы все равно захотите опустить пиксели по краям:

edges = (indices == 0).any(axis=-1) | (indices == limit).any(axis=-1)
conn_index = indices[~edges, None, :] + connectivity[None, ...]

index_mask = np.empty(len(indices), dtype=bool)
index_mask[edges] = True
index_mask[~edges] = (im[tuple(conn_index.T)] == 0).any(axis=0)

LA = indices[index_mask, :]

Я ожидаю, что правильно написанный цикл, скомпилированный с помощью numba, будет значительно быстрее, чем это решение, потому что это позволит избежать большей части накладных расходов при конвейерной обработке операций. Это не потребует больших временных буферов или специальной обработки.

TL;DR

# Parameters
RN = np.array([4, 4, 3])
rad = 3

im = ...

# Find subset of interest
cutout = ((np.indices(im.shape) - RN.reshape(-1, 1, 1, 1))**2).sum(axis=0) <= rad**2
solid = im > 0

# Convert mask to indices
indices = np.argwhere(solid & cutout)

# Find image edges among indices
edges = (indices == 0).any(axis=-1) | (indices == limit).any(axis=-1)

# Connectivity elements for non-edge pixels
conn_index = indices[~edges, None, :] + connectivity[None, ...]

# Mask the valid surface pixels
index_mask = np.empty(len(indices), dtype=bool)
index_mask[edges] = True
index_mask[~edges] = (im[tuple(conn_index.T)] == 0).any(axis=0)

# Final result
LA = indices[index_mask, :]
person Mad Physicist    schedule 13.08.2020
comment
ВОТ ЭТО ДА! Это краткий курс по теме :) Большое спасибо, что нашли время и проработали все возможные детали ваших решений. - person Silent; 19.08.2020

Поскольку ваши циклы используют только прямое индексирование Numpy, вы можете использовать Numba @njit для выполнения этого гораздо более эффективным способом.

@njit
def compute_imtemp(imtemp, X, Y, Z):
    for i in range (Z):
        for j in range (Y-1):
            for k in range (X-1):
                if imtemp[i,j,k]==-1:
                    if i==(Z-1): 
                        imtemp[i,j,k]=-2
                    elif imtemp[i+1,j,k] == 0 or imtemp[i-1,j,k] == 0 or imtemp[i,j+1,k] == 0 or imtemp[i,j-1,k] == 0 or imtemp[i,j,k+1] == 0 or imtemp[i,j,k-1] == 0:
                        imtemp[i,j,k]=-2

[...]
imtemp = im.copy()
imtemp[mask] = -1
compute_imtemp(imtemp, X, Y, Z)
LA = np.argwhere(imtemp==-2)

Вот результаты производительности на моей машине:

281 µs ± 1.43 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
776 ns ± 16.4 ns per loop (mean ± std. dev. of 7 runs, 100 loops each)

Реализация Numba в 362 раза быстрее.

Обратите внимание, что первый вызов compute_imtemp будет медленным из-за компиляции. Один из способов преодолеть это — вызвать compute_imtemp для пустого массива Numpy. Другой способ — вручную скомпилировать функцию с помощью Numba API и явно указать типы для Numba.

person Jérôme Richard    schedule 11.08.2020
comment
Спасибо, Джером, за это решение. Я не был знаком с Нумбой. Я успешно его реализовал. Мне все еще интересно, думаете ли вы, что есть более компактный способ записи этих циклов и условий? - person Silent; 12.08.2020
comment
@Тихий. Я был бы рад показать вам, если вы объясните, что именно вы хотите. Нет гарантии, что чистое решение numpy будет быстрее, поскольку скомпилированный цикл на самом деле более эффективен в большинстве случаев. - person Mad Physicist; 12.08.2020
comment
@MadPhysicist спасибо за предложение. Я отредактировал вопрос, чтобы отразить ваши опасения. Я был бы в порядке, чтобы изучить любой элегантный способ сделать это, независимо от того, что он пустой, так как в конце я могу легко преобразовать результаты в пустой и двигаться дальше. - person Silent; 13.08.2020
comment
Есть ли шанс, что я могу попросить вас сравнить мое решение? - person Mad Physicist; 14.08.2020