Python – извлечение ненулевых пикселей изображения RGBA – ускорение цифровой маски

Мне нужно извлечь ненулевые пиксели из изображения RGBA. Код ниже работает, но поскольку мне приходится иметь дело с действительно огромными изображениями, некоторое ускорение будет полезно. Получение «f_mask» — самая долгая задача. Можно ли как-то ускорить работу? Как быстрее удалить строки со всеми нулевыми значениями ([0, 0, 0, 0])?

import numpy as np
import time


img_size = (10000, 10000)
img = np.zeros((*img_size, 4), float)  # Make RGBA image

# Put some values for pixels [float, float, float, int]
img[0][1] = [1.1, 2.2, 3.3, 4]
img[1][0] = [0, 0, 0, 10]
img[1][2] = [6.1, 7.1, 8.1, 0]


def f_img_to_pts(f_img):  # Get non-zero rows with values from whole img array
    f_shp = f_img.shape
    f_newshape = (f_shp[0]*f_shp[1], f_shp[2])
    f_pts = np.reshape(f_img, f_newshape)
    f_mask = ~np.all(f_pts == 0, axis=1)
    f_pts = f_pts[f_mask]
    return f_pts


t1 = time.time()
pxs = f_img_to_pts(img)
t2 = time.time()
print('PIXELS EXTRACTING TIME: ', t2 - t1)
print(pxs)

Пожалуйста, перечислите все возможные типы изображений. Что это значит, когда значения могут быть плавающими? Некоторые из ваших изображений являются np.float32?

ken 12.07.2024 11:53

Можно с уверенностью предположить, что значения будут [float, float, float, int]

dany 12.07.2024 12:03

Отредактированный вопрос

dany 12.07.2024 12:13

Вы не можете поместить значения с плавающей запятой в числовой массив dtype np.uint16. img[0][1] = [1.1, 2.2, 3.3, 4] эквивалентно img[0][1] = [1, 2, 3, 4]. Попробуйте print(img[0][1]).

ken 12.07.2024 12:18

Аааааа, ок, спасибо! Извините за глупую ошибку. Итак, теперь все должно быть установлено как float

dany 12.07.2024 12:28

Хорошо, это проясняет ситуацию! Но возможно я задал неправильный вопрос. Я хотел знать, можно ли избежать чисел с плавающей запятой и всегда использовать uint16 или uint8. Потому что если нам придется иметь дело только с uint16 или uint8, то у нас есть решение, которое будет как минимум в 10 раз быстрее.

ken 12.07.2024 13:55

К сожалению, мне нужно использовать float :/

dany 12.07.2024 14:58
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
2
7
151
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Анализ

~np.all(f_pts == 0, axis=1) неоптимален по нескольким причинам:

Прежде всего, операция ограничена памятью (обычно пропускной способностью ОЗУ), поскольку изображение огромно (400 МБ в памяти) — даже логические маски огромны (100 МБ). Более того, Numpy создает несколько временных массивов: один для результата f_pts == 0, один для np.all(...) и даже один для ~np.all(...). Каждый массив полностью хранится в оперативной памяти и считывается обратно, что неэффективно. Хуже того: вновь выделенные массивы обычно не отображаются напрямую в физическую память из-за особенностей работы виртуальной памяти, поэтому за каждый временный массив приходится платить дорогостоящие накладные расходы, связанные с ошибками страниц. И последнее, но не менее важное: Numpy не оптимизирован для вычислений массивов, где основное измерение (т. е. обычно последнее) крошечное, как здесь (4 элемента). Однако это можно исправить, используя лучшую структуру памяти. См. AoS против SoA для получения дополнительной информации.

Обратите внимание, что np.zeros резервирует некоторое обнуленное пространство в виртуальной памяти, но не отображает его физически. Таким образом, первый вызов f_img_to_pts выполняется медленнее из-за ошибок страницы. В реальных приложениях это, как правило, не так, поскольку запись нулей в img или однократное чтение приводит к физическому отображению всей страницы (т. е. к ошибкам страницы). Если это не так, то вам обязательно следует использовать разреженные матрицы вместо плотных. Предположим, img уже отображен в физической памяти (это можно сделать, запустив код дважды или просто вызвав img.fill(0) после np.zeros.

Также обратите внимание, что dtype float на самом деле является 64-битным типом с плавающей запятой, а не 32-битным. 64-битные числа с плавающей запятой обычно дороже, особенно в кодах, привязанных к памяти, поскольку они занимают вдвое больше места. Вам обязательно следует использовать 32-битные для вычислений изображений, поскольку они почти никогда не требуют такой очень высокой точности. Предположим, что входной массив теперь 32-битный.


Решения

Один из способов уменьшить накладные расходы — это итерация строка за строкой (циклы не так уж и плохи, как здесь кажется, а на самом деле совсем наоборот, если все сделано правильно). Хотя это должно быть быстрее, это все равно неоптимально. Невозможно сделать это намного быстрее из-за особенностей конструкции Numpy.

Чтобы сделать это еще быстрее, можно использовать модули, компилирующие функции Python в нативные функции, такие как Numba или Cython. С их помощью мы можем легко избежать создания временных массивов, специализировать код для 4-канальных изображений и даже использовать несколько потоков. Вот полученный код:

import numpy as np
import time
import numba as nb

img_size = (10000, 10000)
img = np.zeros((*img_size, 4), np.float32)  # Make RGBA image
img.fill(0)

# Put some values for pixels [float, float, float, int]
img[0][1] = [1.1, 2.2, 3.3, 4]
img[1][0] = [0, 0, 0, 10]
img[1][2] = [6.1, 7.1, 8.1, 0]

# Compute ~np.all(f_pts==0,axis=1) and assume most items are 0
@nb.njit('(float32[:,:,::1],)', parallel=True)
def f_img_to_pts_optim(f_img):  # Get non-zero rows with values from whole img array
    n, m, c = f_img.shape
    assert c == 4 # For sake of performance
    f_mask = np.zeros(n * m, dtype=np.bool_)
    f_pts = np.reshape(f_img, (n*m, c))
    for ij in nb.prange(n * m):
        f_mask[ij] = (f_pts[ij, 0] != 0) | (f_pts[ij, 1] != 0) | (f_pts[ij, 2] != 0) | (f_pts[ij, 3] != 0)
    f_pts = f_pts[f_mask]
    return f_pts

t1 = time.time()
pxs = f_img_to_pts_optim(img)
t2 = time.time()
print('PIXELS EXTRACTING TIME: ', t2 - t1)
print(pxs)

Полученные результаты

Вот тайминги на 10-ядерном процессоре Skylake Xeon:

Initial code (64-bit):          1720 ms
Initial code (32-bit):          1630 ms
Optimized code (sequential):     323 ms
Optimized code (parallel):       182 ms   <----------

Оптимизированный код в 9 раз быстрее исходного кода (при том же типе ввода). Обратите внимание, что код не масштабируется в зависимости от количества ядер, поскольку чтение входных данных является узким местом в сочетании с последовательной операцией f_pts[f_mask]. Можно построить f_pts[f_mask], используя несколько потоков (и это должно быть примерно в 2 раза быстрее), но это довольно сложно сделать (особенно с Numba).

Ого! Какой ответ! Большой! Огромное спасибо! Это ясное объяснение тоже потрясающе! Спасибо!

dany 12.07.2024 15:09

Отличный ответ. В самом внутреннем цикле вы присваиваете значение f_mask на каждой итерации. Поскольку вы ранее инициализировали этот массив нулевым значением, есть ли какой-либо выигрыш в скорости при изменении значения только тогда, когда оно должно стать отличным от нуля? Я имею в виду избегать записи, если значение станет нулевым.

Mark Setchell 13.07.2024 09:58

@MarkSetchell Хороший вопрос! Если это сделать, то возникнет условие, и условная ветвь часто не позволяет компиляторам генерировать быстрый SIMD-код. Однако я не проверял, эффективен ли здесь сгенерированный ассемблерный код. Numba не любит логические операторы... Кроме того, скорость зависит от предсказуемости условия (это нормально, если условие очень редко бывает истинным, что в данном случае, безусловно, довольно верно). Так что это зависит. На практике я ожидаю, что запись в f_mask[ij] займет незначительное время. Действительно, f_pts в памяти в 16 раз больше, чем f_mask.

Jérôme Richard 16.07.2024 22:48

@JérômeRichard Спасибо, что нашли время ответить, очень ценю.

Mark Setchell 16.07.2024 22:51

@MarkSetchell Добро пожаловать. Обратите внимание, что эффект, обнаруженный BadEnglish в отношении обнуления страниц, оказывает влияние и на этот случай (по-видимому, только в Linux). Написание заметок на страницах f_mask поможет им остаться нетронутыми, когда вероятность того, что f_mask[i] окажется правдой, очень-очень мала (<0,02%). В этом случае я ожидал, что чтение f_mask вызовет первое касание страницы, приводящее к физическому сопоставлению, но в относительно недавних ядрах Linux это не так. В этом конкретном случае чтение может происходить быстрее, что приводит к небольшому увеличению производительности (в Linux, но, очевидно, не в Windows).

Jérôme Richard 21.07.2024 15:49

Также в Linux не нужно использовать огромные страницы (в зависимости от ядра и системного распределителя). В противном случае преимущество появляется только в том случае, если вероятность составляет <0,00004% для огромных страниц размером 2 МБ (размер по умолчанию для процессоров x86), что глупо мало. На самом деле, это настолько мало, что оно того редко стоит (за исключением случая OP, когда на одной огромной странице размером 2 МБ всего 3 значения).

Jérôme Richard 21.07.2024 15:56

Обычно я «прибегаю» к C++, когда мне нужно ускорить код Python. Однако ответ Жерома Ришара меня впечатлил, особенно использование numba, с которым я не был знаком. В качестве альтернативы, вот параллельное решение с использованием OpenMP, которое обеспечивает пятикратное ускорение по сравнению с подходом на основе JIT (на i7 11700K). Он использует тип __int128, доступный в GCC, для выполнения нулевых тестов для четырех чисел с плавающей запятой одновременно.

Сторона Python:

    #!/usr/bin/env python3
    
    import numpy as np
    from ctypes import *
    from time import time
    import numba as nb
    
    from numpy.ctypeslib import ndpointer
    from math import prod
    
    clib = CDLL('./libfilter.so')
    clib.FindNZ_IndexesF32x4.restype  = c_uint
    clib.FindNZ_IndexesF32x4.argtypes = (c_void_p, # inp: int128 (see f_img_to_pts_cpp(..))
                                         ndpointer(c_uint,
                                                   flags = "C_CONTIGUOUS"), # indexes
                                         c_uint) # size
    
    
    def f_img_to_pts_cpp(inp):
        cpp   = 4 # components per pixel
        assert inp.shape[-1] == cpp
    
        flat_size = prod(inp.shape)
        inp       = inp.reshape(flat_size)
    
        # The ctypes module doesn't have a 128-bit type, so we manually assert
        # the preconditions for safe casting
        # (except alignment, but it works as it is, at least on x86_64)
        assert inp.flags['C_CONTIGUOUS']
        assert inp.dtype == np.float32
    
        inp     = inp.view(np.complex128) # cast to 128-bit elements
        indexes = np.empty(flat_size, dtype=np.uint32) # uninitialzed array
        size    = clib.FindNZ_IndexesF32x4(inp.ctypes.data, 
                                           indexes,
                                           flat_size//cpp)
        indexes = indexes[:size]
        out = inp[indexes].view(np.float32) # cast back to float
        return out.reshape((size, cpp))
    
    
    @nb.njit('(float32[:,:,::1],)', parallel=True)
    def f_img_to_pts_optim(f_img):  # Get non-zero rows with values from whole img array
        n, m, c = f_img.shape
        assert c == 4 # For sake of performance
        f_mask = np.zeros(n*m, dtype=np.bool_)
        f_pts = np.reshape(f_img, (n*m, c))
        for ij in nb.prange(n*m):
            f_mask[ij] = (f_pts[ij, 0] != 0) | (f_pts[ij, 1] != 0) | \
                (f_pts[ij, 2] != 0) | (f_pts[ij, 3] != 0)
        f_pts = f_pts[f_mask]
        return f_pts
    
    
    def py_opt(img):
        print('warming up...') # "spin up" the processor 
        for i in range(4):
            f_img_to_pts_optim(img)
    
        tstart = time()
        ret = f_img_to_pts_optim(img)
        runtime = time() - tstart
        print(f'numba runtime {runtime:.4f}')
        return ret
    
    def cpp_opt(img):
        tstart = time()
        pxs = f_img_to_pts_cpp(img)
        runtime = time() - tstart
        print(f'C++ runtime {runtime:.4f}')
        return pxs
    
    img = np.zeros((10000, 10000, 4), np.float32)
    
    img[0][1] = [1.1, 2.2, 3.3, 4]
    img[1][0] = [0,   0,   0,   10]
    img[1][2] = [6.1, 7.1, 8.1, 0]
    
    py_pxs  = py_opt(img)
    cpp_pxs = cpp_opt(img)
    
    print(py_pxs)
    assert (py_pxs == cpp_pxs).all()

Сторона С++:

#include <cstdint>
#include <atomic>

using u32    = uint32_t;
using int128 = __int128;

extern "C"
u32 FindNZ_IndexesF32x4(const int128 *  inp,
                        u32          *  indexes,
                        const u32 size) noexcept {
  std::atomic_uint l(0);

  #pragma omp parallel for
  for(u32 i = 0; i < size; i++) {
    if (inp[i]) {
      u32 j = l.fetch_add(1);
      indexes[j] = i;
    }
  }

  return l;
}

Чтобы построить исходный код выше:

g++ -DNDEBUG -fPIC -Ofast -mtune=native -fopenmp -std=gnu++17 -shared -o libfilter.so filter.cpp

Выход:

warming up...
numba runtime 0.1169
C++ runtime 0.0163
[[ 1.1  2.2  3.3  4. ]
 [ 0.   0.   0.  10. ]
 [ 6.1  7.1  8.1  0. ]]

РЕДАКТИРОВАТЬ

Как правильно заметил @Jérôme Richard, недетерминированная природа потоков означает, что порядок индексов (и, следовательно, порядок результата) может отличаться от варианта JIT. Однако, поскольку количество ненулевых элементов крайне мало, сортировка индексов практически не требует затрат.

Согласование: согласно этому Почему malloc выровнен по 16 байтам?, Адреса, выделенные с помощью malloc, в системе x86_64 выравниваются по 16 байтам, если только запрошенный размер не меньше 15 байт, что не имеет место в данном контексте.

#include <cstdint>
#include <atomic>
#include <algorithm>

using u32    = uint32_t;
using int128 = __int128;

extern "C"
u32 FindNZ_IndexesF32x4(const int128 *  inp,
                        u32          *  indexes,
                        const u32 size) noexcept {
  std::atomic_uint l(0);

  // It's always aligned on a x86_64 system
  // const bool is_misaligned  = (size_t(inp) & 15) != 0;

  // Regarding -0.0:
  int128    mask;
  {
    u32 * u = (u32*)&mask;
    u[0] = (1u << 31) - 1;
    u[1] = u[0];
    u[2] = u[0];
    u[3] = u[0];
  }

  #pragma omp parallel for
  for(u32 i = 0;  i < size;  i++) {
    if (inp[i] & mask) {
      u32 j = l.fetch_add(1);
      indexes[j] = i;
    }
  }

  std::sort(indexes, indexes + l);

  return l;
}

Вывод (с маскированием и сортировкой, i7 11700K):

warming up...
numba runtime 0.142
C++ runtime 0.0171
[[ 1.1  2.2  3.3  4. ]
 [ 0.   0.   0.  10. ]
 [ 6.1  7.1  8.1  0. ]]

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

ОБНОВЛЯТЬ

@Jérôme Richard сделал еще одно наблюдение: эффективная пропускная способность памяти в моих результатах кажется слишком хорошей, чтобы быть правдой. Мне потребовалось некоторое тестирование и поиск в Google, чтобы понять, что происходит. Рассмотрим эту функцию:

...
extern "C"
u64 CPP_Reduce(const u64 * inp, const u32 size) noexcept {
  u64 acc = 0;
  #pragma omp parallel for reduction(+: acc)
  for(u32 i = 0;  i < size;  i++)
    acc += inp[i];
  return acc;
}
...

Давайте передадим img из Python:

    clib.CPP_Reduce.argtypes = (ndpointer(c_uint64,
                                          flags = "C_CONTIGUOUS"),
                                c_uint) # size
    clib.CPP_Reduce.restype = c_uint64
...
    as_vec64 = img.reshape(prod(img.shape)).view(dtype=np.uint64)
    print(f'reduction input: size {as_vec64.nbytes}  shape {as_vec64.shape}')

    n_iters = 10
    tstart = time()
    for i in range(n_iters):
        clib.CPP_Reduce(as_vec64, as_vec64.shape[0])
    dt = (time() - tstart) / n_iters
    print(f'dt: {dt:.4f} s')
    bandwidth = as_vec64.nbytes / dt / 2**30
    print(f'bandwidth: {bandwidth:.4f} GB/s')
...

Выход:

reduction input: size 1600000000  shape (200000000,)
dt: 0.0123 s
bandwidth: 120.8112 GB/s

Это i5 1335U с тактовой частотой оперативной памяти 4,267 ГГц. Общая ширина шины составляет 128 бит. Таким образом, его пиковая пропускная способность составляет 4,267e9 * 16/2**30 = 63,58 ГБ/с. На первый взгляд может показаться, что пример приведения некорректен, но это не так (надеюсь). Давайте запустим нашу функцию сокращения с другим входом:

img = np.arange(0, 10000*10000*4, 1, np.float32).reshape((10000, 10000, 4))

Выход:

reduction input: size 1600000000  shape (200000000,)
dt: 0.0458 s
bandwidth: 32.5458 GB/s

Этот результат гораздо менее удивителен (и, вероятно, не очень хорош, но это не по теме). Вы можете прочитать объяснение здесь: Ядро Linux: роль нулевого выделения страниц во время paging_init По сути, из-за трюков с подкачкой img может эффективно полностью поместиться в кэше ЦП, если он в основном равен нулям, но в противном случае «ведет себя так, как ожидалось».

Интересное решение, но я вижу несколько проблем в коде. Во-первых, я думаю, что атомарность приводит к тому, что результат не сохраняется в том же порядке, что и в коде OP (IDK, если это проблема для OP). Более того, я считаю, что разыменование указателя __int128* — это неопределенное поведение в C++, если не гарантировано выравнивание указателя. Я не уверен inp.view(np.complex128) гарантировать, что мне это кажется небезопасным. Однако memcpy на каждом элементе должен это исправить. Я думаю, что было бы гораздо лучше использовать для этого встроенные функции SIMD (возможно, GCC). Вы можете явно выполнять с ними невыровненную загрузку.

Jérôme Richard 16.07.2024 22:59

Да, и имейте в виду, что -Ofast на самом деле включает -O3 + -ffast-math + несколько других нестандартных опций и -ffast-math опасно, если массивы содержат такие значения, как NaN, Inf, отрицательные нули или субнормальные числа. Я не думаю, что стоит использовать здесь этот небезопасный вариант. Отрицательные нули не равны 0 после переинтерпретации в int128, поэтому перед проверкой необходимо удалить знак бита с помощью маски.

Jérôme Richard 16.07.2024 23:11

Это лучше :) . Примечание __declspec(dllexport) требуется для работы кода в Windows.

Jérôme Richard 18.07.2024 19:52

Я получаю ускорение в 3,8 раза по сравнению с кодом Numba (это хорошо, но не так хорошо, как упоминалось, но близко). Я думаю, что код привязан к памяти на моей машине (ОЗУ 40 ГиБ/с). Ваши тайминги удивительны, так как это будет означать 10000**2*4*4/0.0171/1024**3=87 GiB/s пропускную способность ОЗУ (изображение не помещается в кеш процессора). Предполагается, что ваш процессор в лучшем случае поддерживает ОЗУ 50 ГиБ/с (и только 2 модуля DIMM DDR4). Такие результаты указывают на DDR4 >= 6000 МГц, которого AFAIK не существует (и в любом случае он не будет поддерживаться вашим процессором). Я смущен. Я что-то пропустил?

Jérôme Richard 18.07.2024 19:54

Отличное объяснение! Я могу воспроизвести этот эффект в Linux, выделив массив np.zeros и затем просуммировав значения с помощью np.sum. С пустыми/несопоставленными страницами это происходит в два раза быстрее, чем с сопоставленными (все еще с нулями). Изначально я тестировал код на Windows, где такого эффекта не увидел, так что, возможно, Windows не выполняет такую ​​оптимизацию виртуальной страницы (или с ней не намного быстрее). В моем Linux я получаю 0,1239 для Numba и 0,0105 для вашего исходного кода. Это примерно в 12 раз быстрее. За счет этого эффекта это намного больше, чем в Windows (у меня достигается 141 ГиБ/с на 40 ГиБ/с ОЗУ).

Jérôme Richard 21.07.2024 15:37

Благодарим вас за то, что нашли время изучить проблему с производительностью. В целом оно того стоит, поскольку помогает узнать что-то новое и/или повысить точность прошлых/будущих ответов ;).

Jérôme Richard 21.07.2024 16:02

Другие вопросы по теме