Самый быстрый подход к чтению тысяч изображений в один большой массив numpy

Я пытаюсь найти самый быстрый способ прочитать кучу изображений из каталога в массив numpy. Моя конечная цель - вычислить такие статистические данные, как max, min и nth percentile пикселей из всех этих изображений. Это просто и быстро, когда пиксели из всех изображений находятся в одном большом массиве numpy, так как я могу использовать встроенные методы массива, такие как .max и .min, и функцию np.percentile.

Ниже приведены несколько примерных таймингов с 25 tiff-изображениями (512x512 пикселей). Эти тесты ориентированы на использование %%timit в jupyter-ноутбуке. Различия слишком малы, чтобы иметь какие-либо практические последствия только для 25 изображений, но я намерен читать тысячи изображений в будущем.

# Imports
import os
import skimage.io as io
import numpy as np
  • Добавление в список

    %%timeit
    imgs = []    
    img_path = '/path/to/imgs/'
    for img in os.listdir(img_path):    
        imgs.append(io.imread(os.path.join(img_path, img)))    
    ## 32.2 ms ± 355 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
    
  • Использование словаря

    %%timeit    
    imgs = {}    
    img_path = '/path/to/imgs/'    
    for img in os.listdir(img_path):    
        imgs[num] = io.imread(os.path.join(img_path, img))    
    ## 33.3 ms ± 402 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
    

Для перечисленных выше и словарных подходов я попытался заменить цикл соответствующим пониманием с аналогичными результатами по времени. Я также пробовал перераспределять словарные ключи без существенной разницы во времени. Чтобы получить изображения из списка в большой массив, я бы использовал np.concatenate(imgs), который занимает всего ~ 1 мс.

  1. Предварительное выделение массива numpy по первому размеру

    %%timeit    
    imgs = np.ndarray((512*25,512), dtype='uint16')    
    img_path = '/path/to/imgs/'    
    for num, img in enumerate(os.listdir(img_path)):    
        imgs[num*512:(num+1)*512, :] = io.imread(os.path.join(img_path, img))    
    ## 33.5 ms ± 804 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
    
  2. Предварительное выделение numpy по третьему размеру

    %%timeit    
    imgs = np.ndarray((512,512,25), dtype='uint16')    
    img_path = '/path/to/imgs/'    
    for num, img in enumerate(os.listdir(img_path)):    
        imgs[:, :, num] = io.imread(os.path.join(img_path, img))    
    ## 71.2 ms ± 2.22 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
    

Первоначально я думал, что методы превалирования numpy будут быстрее, поскольку в цикле динамического расширения переменных нет, но это, похоже, не так. Подход, который я нахожу наиболее интуитивным, является последним, когда каждое изображение занимает отдельные размеры вдоль третьей оси массива, но это также самое медленное. Дополнительное время не связано с самим предварительным использованием, которое занимает только 1 мс.

У меня есть три вопроса относительно этого:

  • Почему превалирование numpy приближается не быстрее, чем словарный список и список решений?
  • Какой самый быстрый способ чтения в тысячах изображений в один массив с большим количеством символов?
  • Могу ли я извлечь пользу из внешнего вида numpy и scikit-image, для еще более быстрого модуля для чтения изображений? Я пробовал plt.imread(), но модуль scikit-image.io работает быстрее.

Ответ 1

Часть A: доступ и назначение массивов NumPy

Идя тем, что элементы хранятся в строчном порядке для массивов NumPy, вы делаете правильную вещь, сохраняя эти элементы вдоль последней оси на итерацию. Они занимали бы смежные области памяти и, как таковые, были бы наиболее эффективными для доступа и назначения значений. Таким образом, инициализации, такие как np.ndarray((512*25,512), dtype='uint16') или np.ndarray((25,512,512), dtype='uint16'), будут работать наилучшим образом, как упомянуто в комментариях.

После компиляции в качестве funcs для тестирования по таймингам и подачи в случайные массивы вместо изображений -

N = 512
n = 25
a = np.random.randint(0,255,(N,N))

def app1():
    imgs = np.empty((N,N,n), dtype='uint16')
    for i in range(n):
        imgs[:,:,i] = a
        # Storing along the first two axes
    return imgs

def app2():
    imgs = np.empty((N*n,N), dtype='uint16')
    for num in range(n):    
        imgs[num*N:(num+1)*N, :] = a
        # Storing along the last axis
    return imgs

def app3():
    imgs = np.empty((n,N,N), dtype='uint16')
    for num in range(n):    
        imgs[num,:,:] = a
        # Storing along the last two axes
    return imgs

def app4():
    imgs = np.empty((N,n,N), dtype='uint16')
    for num in range(n):    
        imgs[:,num,:] = a
        # Storing along the first and last axes
    return imgs

Сроки -

In [45]: %timeit app1()
    ...: %timeit app2()
    ...: %timeit app3()
    ...: %timeit app4()
    ...: 
10 loops, best of 3: 28.2 ms per loop
100 loops, best of 3: 2.04 ms per loop
100 loops, best of 3: 2.02 ms per loop
100 loops, best of 3: 2.36 ms per loop

Те тайминги подтверждают теорию производительности, предложенную в начале, хотя я ожидал, что тайминги для последней настройки будут иметь тайминги между значениями для app3 и app1, но, возможно, эффект перехода от последнего к первому ось для доступа и назначения не является линейной. Более подробные исследования по этому вопросу могут быть интересными (здесь).

Чтобы схематически рассмотреть схему, рассмотрим, что мы храним массивы изображений, обозначенные символом x (изображение 1) и o (изображение 2), мы имели бы:

App1:

[[[x 0]
  [x 0]
  [x 0]
  [x 0]
  [x 0]]

 [[x 0]
  [x 0]
  [x 0]
  [x 0]
  [x 0]]

 [[x 0]
  [x 0]
  [x 0]
  [x 0]
  [x 0]]]

Таким образом, в пространстве памяти это будет: [x,o,x,o,x,o..] следующий порядок строк.

App2:

[[x x x x x]
 [x x x x x]
 [x x x x x]
 [o o o o o]
 [o o o o o]
 [o o o o o]]

Таким образом, в пространстве памяти это будет: [x,x,x,x,x,x...o,o,o,o,o..].

App3:

[[[x x x x x]
  [x x x x x]
  [x x x x x]]

 [[o o o o o]
  [o o o o o]
  [o o o o o]]]

Таким образом, в пространстве памяти он будет таким же, как и предыдущий.


Часть B: Чтение изображения с диска как массива

Теперь, когда часть чтения изображения, я видел, что OpenCV imread будет намного быстрее.

В качестве теста я загрузил изображение Моны Лизы с вики-страницы и протестировал производительность при чтении изображений -

import cv2 # OpenCV

In [521]: %timeit io.imread('monalisa.jpg')
100 loops, best of 3: 3.24 ms per loop

In [522]: %timeit cv2.imread('monalisa.jpg')
100 loops, best of 3: 2.54 ms per loop

Ответ 2

В этом случае большая часть времени будет потрачена на чтение файлов с диска, и я не стал бы слишком беспокоиться о времени, чтобы заполнить список.

В любом случае, это script сравнение четырех методов без накладных расходов на чтение фактического изображения с диска, а просто чтение объекта из памяти.

import numpy as np
import time
from functools import wraps


x, y = 512, 512
img = np.random.randn(x, y)
n = 1000


def timethis(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        r = func(*args, **kwargs)
        end = time.perf_counter()
        print('{}.{} : {} milliseconds'.format(func.__module__, func.__name__, (end - start)*1e3))
        return r
    return wrapper


@timethis
def static_list(n):
    imgs = [None]*n
    for i in range(n):
        imgs[i] = img
    return imgs


@timethis
def dynamic_list(n):
    imgs = []
    for i in range(n):
        imgs.append(img)
    return imgs


@timethis
def list_comprehension(n):
    return [img for i in range(n)]


@timethis
def numpy_flat(n):
    imgs = np.ndarray((x*n, y))
    for i in range(n):
        imgs[x*i:(i+1)*x, :] = img

static_list(n)
dynamic_list(n)
list_comprehension(n)
numpy_flat(n)

Результаты показывают:

__main__.static_list : 0.07004200006122119 milliseconds
__main__.dynamic_list : 0.10294799994881032 milliseconds
__main__.list_comprehension : 0.05021800006943522 milliseconds
__main__.numpy_flat : 309.80870099983804 milliseconds

Очевидно, что ваш лучший выбор - это понимание списка, однако даже при заполнении массива numpy его всего 310 мс для чтения 1000 изображений (из памяти). Таким образом, накладные расходы будут считаны на диске.

Почему numpy медленнее?

Это то, как numpy хранит массив в памяти. Если мы изменим функции списка python для преобразования списка в массив numpy, времена будут похожи.

Измененные функции возвращают значения:

@timethis
def static_list(n):
    imgs = [None]*n
    for i in range(n):
        imgs[i] = img
    return np.array(imgs)


@timethis
def dynamic_list(n):
    imgs = []
    for i in range(n):
        imgs.append(img)
    return np.array(imgs)


@timethis
def list_comprehension(n):
    return np.array([img for i in range(n)])

и результаты синхронизации:

__main__.static_list : 303.32892100022946 milliseconds
__main__.dynamic_list : 301.86925499992867 milliseconds
__main__.list_comprehension : 300.76925699995627 milliseconds
__main__.numpy_flat : 305.9309459999895 milliseconds

Так что это всего лишь несколько вещей, что требуется больше времени, и это постоянное значение относительно размера массива...