Документация Engee

Массивы, числа и цвета

В Julia изображение — это просто массив, и способы работы с изображениями во многом такие же, как с многомерными массивами. Например, следующий код:

julia> img = rand(2,2)
2×2 Matrix{Float64}:
 0.366796  0.210256
 0.523879  0.819338

определяет «изображение» img, состоящее из 64-битных чисел с плавающей запятой. Этот объект можно использовать как изображение в большинстве или даже во всех функциях JuliaImages.

Мы часто будем вести речь о работе с массивами. На этой странице основное внимание уделяется «типу элементов» (eltype), хранящихся в массиве. Для тех, у кого нет опыта работы с Julia, поясним: если a является массивом целых чисел:

julia> a = [1,2,3,4]
4-element Vector{Int64}:
 1
 2
 3
 4

создать массив с типом элементов Float64 можно одним из следующих способов:

map(Float64, a)
Float64.(a)     # краткая форма записи для broadcast(Float64, a)

Пример:

julia> Float64.(a)
4-element Vector{Float64}:
 1.0
 2.0
 3.0
 4.0

Индексы массивов указываются в квадратных скобках (a[1]), причем по умолчанию индексирование начинается с 1. К двухмерному массиву, такому как img, можно обратиться по индексу так: img[2,1]. Это означает обращение ко второй строке, первому столбцу. В Julia также поддерживается «линейное индексирование» с использованием одного целого числа для обращения к элементам произвольного многомерного массива согласно (в простых случаях) их смещению в памяти. Например, img[3] равносильно img[1,2] (нумерация следует по столбцам с переходом в начало следующего столбца по достижении конца предыдущего, так как в Julia массивы хранятся в памяти по столбцам и самым быстрым измерением является первое).

Числа и цвета

Созданный выше массив img можно отобразить как изображение в оттенках серого с помощью ImageView. Но если вы выполняете примеры в Juno или IJulia, то видите, что img выводится не как изображение, а как представленный выше массив чисел. Массивы «обычных чисел» не отображаются графически, так как могут представлять что-то числовое (например, матрицу в линейной алгебре), а не изображение. Чтобы указать, что объект должен быть представлен графически, преобразуйте тип элементов в цвета из пакета Colors:

float_gray

Здесь мы использовали цвет Gray, чтобы указать, что массив должен интерпретироваться как изображение в оттенках серого. (Имейте в виду, что пакет Colors экспортируется вместе с Images, поэтому можно просто использовать директиву using Images.)

Что делает Gray на внутреннем уровне? Для наглядности можно вывести «необработанный» объект в виде текста:

float_gray_text

(Пользователи Juno или интерфейса командной строки Julia REPL увидят это представление сразу же.)

Как видите, это массив 2×2 объектов Gray{Float64}. Вам может быть любопытно, какое представление у этих объектов Gray. В командной строке REPL оно выглядит так (эта же самая команда работает в IJulia):

julia> dump(imgg[1,1])
ColorTypes.Gray{Float64}
  val: Float64 0.36679641243992434

dump выводит «внутреннее» представление объекта. Как видите, Gray — это тип (строго говоря, неизменяемая структура struct) с единственным полем val; для Gray{Float64} val — 64-битное число с плавающей запятой. Использовать val напрямую не рекомендуется: извлечь значение Float64 можно с помощью функции доступа real или gray (почему вторая функция так называется, станет понятнее при обсуждении цветов RGB).

Какие издержки влекут эти объекты?

julia> sizeof(img)
32

julia> sizeof(imgg)
32

Ответ — никаких: сами по себе они не занимают памяти и обычно не требуют дополнительных вычислений. «Оболочка» Gray — это просто интерпретация значений, помогающая понять, что объект должен отображаться как изображение в оттенках серого. И действительно, img и imgg оказываются равными:

julia> img == imgg
true

Этим данная тема не исчерпывается, но мы продолжим разговор о ней при обсуждении преобразований и представлений.

Другие цвета

Gray — не единственный цвет во вселенной:

randrgb

Давайте выведем imgc как текст (в данном случае в REPL):

julia> imgc
2×2 Array{ColorTypes.RGB{Float32},2}:
 RGB{Float32}(0.75509,0.965058,0.65486)     RGB{Float32}(0.696203,0.142474,0.783316)
 RGB{Float32}(0.705195,0.953892,0.0744661)  RGB{Float32}(0.571945,0.42736,0.548254)

julia> size(imgc)
(2,2)

julia> dump(imgc[1,1])
ColorTypes.RGB{Float32}
  r: Float32 0.7550899
  g: Float32 0.9650581
  b: Float32 0.65485954

Здесь можно увидеть одно из основных отличий подхода к изображениям в Julia от ряда других популярных фреймворков: в imgc нет измерения массива, выделенного для «цветового канала». Вместо этого каждый элемент массива представляет всю полноту информации о пикселе. Это позволяет упростить логику многих алгоритмов, а иногда одна реализация может работать как для цветных изображений, так и для изображений в оттенках серого.

Получить отдельные цветовые каналы можно по именам полей (r, g и b), но, как вы скоро увидите, более универсальным подходом является использование функций доступа:

julia> c = imgc[1,1]; (red(c), green(c), blue(c))
(0.7550899f0,0.9650581f0,0.65485954f0)

Пакет Julia Colors позволяет представить один и тот же цвет по-разному, что может упростить взаимодействие с другими инструментами. Например, в некоторых библиотеках C допускается или общепринята другая очередность цветовых каналов:

julia> dump(BGR(c))
ColorTypes.BGR{Float32}
  b: Float32 0.65485954
  g: Float32 0.9650581
  r: Float32 0.7550899

Красный, зеленый и синий цвета могут также упаковываться вместе с пустым альфа-каналом (означающим прозрачность) в одно 32-разрядное целое число:

julia> c24 = RGB24(c); dump(c24)
ColorTypes.RGB24
  color: UInt32 12711591

julia> c24.color
0x00c1f6a7

Здесь каналы следуют в следующем порядке от первого (первые две шестнадцатеричные цифры после 0x) до последнего (последние две шестнадцатеричные цифры): альфа, красный, зеленый, синий:

julia> 0xc1/0xff
0.7568627450980392

julia> 0xf6/0xff
0.9647058823529412

julia> 0xa7/0xff
0.6549019607843137

Эти значения близки к каналам в c, но округлены — каждый канал кодируется всего 8 битами, поэтому неизбежна некоторая аппроксимация точного значения с плавающей запятой.

Постоянный масштаб для цветов с плавающей запятой и целочисленных цветов: числа с фиксированной запятой

У c24 нет поля r, но мы все же можем получить красный канал с помощью red:

julia> r = red(c24)
0.757N0f8

Поначалу это может показаться несколько странным, поэтому давайте разберемся внимательно. Во-первых, обратите внимание, что часть этого значения в виде числа с плавающей запятой соответствует (с точностью до округления) значению red(c). Часть N0f8 означает следующее: «нормализованное (Normalized), с 8 битами дробной части (8 fractional bits) и 0 битами для представления значений более 1». Это число с фиксированной запятой — оно похоже на число с плавающей запятой, но запятая не «плавает». На внутреннем уровне такое число представлено как 8-разрядное целое число без знака UInt8:

julia> dump(r)
FixedPointNumbers.N0f8
  i: UInt8 193

(Обратите внимание, что N0f8 — это сокращение; полное имя типа — Normed{UInt8, 8}.) N0f8 означает, что это 8-разрядное целое число должно интерпретироваться как значение в диапазоне от 0 до 1, где 0 соответствует 0x00, а 1 — 0xff. Такая интерпретация влияет на то, как число используется в арифметических операциях и преобразуется в другие значения и обратно. Иначе говоря, для r верно следующее:

julia> r == 193/255
true

практически во всех случаях (однако см. A note on arithmetic overflow).

Это обстоятельство имеет одно очень важное последствие: во многих других фреймворках для работы с изображениями «смысл» изображения зависит от того, как оно хранится, однако в Julia он может быть независим от представления в памяти. Например, на другом языке или в другом фреймворке следующая последовательность:

img = uint8(255*rand(10, 10, 3));
figure; image(img)
imgd = double(img);   % convert to double-precision, but don't change the values
figure; image(imgd)

может давать следующие изображения:

img imgd

checker

checker2

Изображение справа выглядит белым, потому что типы с плавающей запятой интерпретируются как лежащие на цветовой шкале 0—​1 (а все элементы в img имеют значение 1 или более), в то время как тип uint8 интерпретируется как лежащий на цветовой шкале 0—​255. К сожалению, два массива с идентичными числовыми значениями имеют совершенно разный смысл как изображения.

Многие фреймворки предлагают вспомогательные функции для преобразования изображений из одного представления в другое, но при сравнении изображений это может быть источником ошибок: в большинстве числовых систем само собой разумеется, что 255 != 1.0, и этот факт означает, что иногда нужно быть очень осторожным при преобразовании одного представления в другое. Напротив, при использовании данных пакетов Julia способ кодирования изображений с помощью чисел с плавающей запятой или 8-разрядных (либо 16-разрядных) чисел с фиксированной запятой не влияет на смысл: 0 всегда означает «черный», а 1 или большее значение всегда означает «белый» или «насыщенный».

Однако это не мешает вам создавать пиксели со значениями вне данного диапазона:

saturated_spectrum

Обратите внимание, что первые два оттенка желтого выглядят одинаково, потому что и красный, и зеленый цветовые каналы имеют значение 1 или более и, следовательно, являются насыщенными.

Однако следует учитывать, что для целочисленных входных значений по умолчанию используется тип элементов N0f8, который не может представлять значения вне диапазона от 0 до 1:

julia> using ColorTypes # скрыть


julia> RGB(8,2,0)
ERROR: ArgumentError: (8, 2, 0) are integers in the range 0-255, but integer inputs are encoded with the N0f8
  type, an 8-bit type representing 256 discrete values between 0 and 1.
  Consider dividing your input values by 255, for example: RGB{N0f8}(8/255,2/255,0/255)
  Or use `reinterpret(N0f8, x)` if `x` is a `UInt8`.
  See the READMEs for FixedPointNumbers and ColorTypes for more information.

В сообщении об ошибке поясняется, как устранить распространенную ошибку, возникающую при попытке построить красный цвет как RGB(255, 0, 0). В Julia для этого всегда следует использовать RGB(1, 0, 0).

Другие числа с фиксированной запятой

16-разрядные изображения можно выразить через тип N0f16. Сравним максимальные значения (typemax) и наименьшую разницу (eps), которые можно представить с помощью типов N0f8 и N0f16:

julia> using FixedPointNumbers

julia> (typemax(N0f8), eps(N0f8))
(1.0N0f8, 0.004N0f8)

julia> (typemax(N0f16), eps(N0f16))
(1.0N0f16, 2.0e-5N0f16)

Как видите, этот тип также имеет максимальное значение 1, но более высокую точность с гораздо меньшим интервалом между соседними числами.

Многие камеры (особенно научные) сейчас возвращают 16-разрядные значения. Однако некоторые камеры предоставляют информацию не на все 16 разрядов; например, камера может быть 12-разрядной и возвращать значения от 0x0000 до 0x0fff. При использовании типа N0f16 последнее значение будет отображаться как почти черное:

12bit_black

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

Это служит еще одним свидетельством одной из основных проблем, которая возникает, когда предполагается, что представление (16-разрядное целое число) также описывает смысл числа. В Julia эта связь разрывается благодаря использованию различных числовых типов с фиксированной запятой. В данном случае такие значения естественным образом выражаются с помощью чисел с фиксированной запятой с 12 битами дробной части. В результате остается 4 бита для представления значений больше 1, поэтому такой числовой тип называется N4f12:

julia> (typemax(N4f12), eps(N4f12))
(16.0037N4f12, 0.0002N4f12)

Как видите, максимальное значение, выражаемое через N4f12, составляет приблизительно 16 = 2^4.

При использовании интерпретации N4f12 для 16 бит цвет правильно отображается как белый:

12bit_black

а в арифметических операциях интерпретируется как 1. Хотя базовое представление будет тем же (0x0fff), мы можем наделить число определенным смыслом посредством его типа.

Примечание об арифметическом переполнении

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

Важно отметить, что при арифметических операциях с числами типа N0f8, так же как и с числами типа UInt8, происходит переполнение:

julia> 0xff + 0xff
0xfe

julia> 1N0f8 + 1N0f8
0.996N0f8

julia> 0xfe/0xff      # первый результат соответствует второму
0.996078431372549

Поэтому значения рекомендуется накапливать в соответствующем типе с плавающей запятой, например Float32, Gray{Float64} или RGB{Float32}.