Преобразования и представления
Совместное использование памяти: введение в представления
В разделе Массивы, числа и цвета мы обсуждали, как можно преобразовать тип элементов массива a = [1,2,3,4]
, используя синтаксис наподобие Float64.(a)
. Вам может быть интересно, какой эффект дает применение Int.(a)
, если он вообще есть:
julia> a = [1,2,3,4]
4-element Vector{Int64}:
1
2
3
4
julia> b = Int.(a)
4-element Vector{Int64}:
1
2
3
4
Никаких очевидных изменений, естественно, не произошло, и, как и ожидалось, b == a
возвращает true
. Помимо одинакового размера и элементов, существует и более широкое понимание «одинаковости»: ссылаются ли a
и b
на одну и ту же область в памяти? Это можно проверить следующими способами:
julia> a === b # обратите внимание: 3 знака равенства!
false
или, в более общем случае, задав одно значение и посмотрев, отражается ли изменение в другом:
julia> b[1] = 5
5
julia> b
4-element Vector{Int64}:
5
2
3
4
julia> a
4-element Vector{Int64}:
1
2
3
4
Поскольку типы объектов a
и b
идентичны, обе проверки говорят нам о том, что a
и b
являются независимыми объектами, даже если у них (изначально) были одинаковые значения.
Связано это с тем, что вызов f.(a)
(который вызывает функцию broadcast(f, a)
) всегда выделяет в памяти новый массив для возврата значений. Однако так работают не все функции. Хорошим примером может служить view
:
julia> v = view(a, :)
4-element view(::Vector{Int64}, :) with eltype Int64:
1
2
3
4
v
и a
имеют одинаковые значения, но это опять-таки разные объекты:
julia> v == a
true
julia> v === a
false
Однако они занимают одну и ту же область памяти:
julia> v[1] = 10
10
julia> v
4-element view(::Vector{Int64}, :) with eltype Int64:
10
2
3
4
julia> a
4-element Vector{Int64}:
10
2
3
4
Следовательно, v
— это «представление» значений, хранящихся в a
. Это простейший вариант использования функции view
. В более общем случае ее можно использовать для выбора прямоугольной области интереса, что является обычной операцией при обработке изображений. Эта область выбирается без копирования каких-либо данных, и любые изменения значений в ней отражаются в исходном (родительском) массиве. Дополнительные сведения см. в документации по view
, которую можно просмотреть, введя ?view
.
view
— это не единственная функция с таким свойством; еще одним хорошим примером является функция reshape
, с помощью которой можно изменять измерения массива:
julia> r = reshape(a, 2, 2)
2×2 Matrix{Int64}:
10 3
2 4
julia> r[1,2] = 7
7
julia> r
2×2 Matrix{Int64}:
10 7
2 4
julia> a
4-element Vector{Int64}:
10
2
7
4
Обратите внимание, что возвращаемый тип reshape
— это просто массив Array
, который служит представлением a
. Однако для некоторых входных данных невозможно создать представление в виде Array
. Например:
julia> r = reshape(1:15, 3, 5)
3×5 reshape(::UnitRange{Int64}, 3, 5) with eltype Int64:
1 4 7 10 13
2 5 8 11 14
3 6 9 12 15
UnitRange
представлен компактно — хранятся только начальные и конечные значения, поэтому получить доступ ко всем значениям, обратившись к определенной области памяти, невозможно. В таких случаях reshape
возвращает тип ReshapedArray
, который является универсальным «типом представления», поддерживающим изменение формы любого AbstractArray
.
Результатом обеих функций view
и reshape
всегда является представление: внесите изменение в родительский объект, и оно отразится в представлении, и наоборот.
Представления для «преобразования» между представлением с фиксированной запятой и необработанным представлением
В разделе Массивы, числа и цвета также были рассмотрены числа с фиксированной запятой, используемые в некоторых представлениях цветовой (или полутоновой) информации. Для изменения представления можно воспользоваться функцией reinterpret
:
julia> using FixedPointNumbers
julia> x = 0.5N0f8
0.502N0f8
julia> y = reinterpret(x) # либо используйте reinterpret(UInt8, x)
0x80
julia> reinterpret(N0f8, y)
0.502N0f8
Ее можно применять к массивам:
julia> a = [0.2N0f8, 0.8N0f8]
2-element Array{N0f8,1} with eltype N0f8:
0.2N0f8
0.8N0f8
julia> b = reinterpret.(a)
2-element Vector{UInt8}:
0x33
0xcc
Из-за вызова f.(a)
объект b
не находится в той же области памяти, что и a
:
julia> b[2] = 0xff
0xff
julia> a
2-element Array{N0f8,1} with eltype N0f8:
0.2N0f8
0.8N0f8
Часто это не является проблемой, но иногда может потребоваться, чтобы они ссылались на один и тот же базовый объект. Для таких случаев в JuliaImages с помощью пакета ImageCore (который входит в состав Images) реализованы представления, которые могут выполнять такую переинтерпретацию:
julia> using Images
julia> v = rawview(a)
2-element reinterpret(UInt8, ::Array{N0f8,1}):
0x33
0xcc
julia> v[2] = 0xff
0xff
julia> a
2-element Array{N0f8,1} with eltype N0f8:
0.2N0f8
1.0N0f8
Из приведенного ниже кода ясно, что v
является неизменяемым объектом или просто ссылкой на a
. Для него не выделена отдельная память:
julia> a = [0.2N0f8,0.8N0f8]
2-element Array{N0f8,1} with eltype N0f8:
0.2N0f8
0.8N0f8
julia> v = rawview(a)
2-element reinterpret(UInt8, ::Array{N0f8,1}):
0x33
0xcc
julia> pointer_from_objref(a) #функция, используемая для нахождения адреса объекта
Ptr{Nothing} @0x000000011cbb19f0
julia> pointer_from_objref(v) #v — это просто неизменяемая ссылка на a, для нее не выделена отдельная память.
ERROR: pointer_from_objref cannot be used on immutable objects
#ERROR: pointer_from_objref cannot be used on immutable objects
Stacktrace:
[1] error(s::String)
@ Base ./error.jl:33
[2] pointer_from_objref(x::Any)
@ Base ./pointer.jl:146
[3] top-level scope
@ none:1
Обратное преобразование — normedview
:
julia> c = [0x11, 0x22]
2-element Vector{UInt8}:
0x11
0x22
julia> normedview(c)
2-element reinterpret(N0f8, ::Vector{UInt8}):
0.067N0f8
0.133N0f8
normedview
позволяет передавать интерпретируемый тип в первом аргументе, то есть normedview(N0f8, A)
, и это действительно необходимо, если только A
не имеет тип элементов UInt8
; в этом случае normedview
предполагает, что требуется N0f8
.
Как и функция reshape
, функции rawview
и normedview
могут возвращать Array
или более сложный тип (ReinterpretArray
или MappedArray
из пакета MappedArrays) в зависимости от типа входных данных.
Цветоделение: представления для преобразования чисел в цвета и наоборот
В разделе Массивы, числа и цвета отмечалось, что числовой массив можно преобразовать в массив оттенков серого с помощью Gray.(a)
. Обратное преобразование можно выполнить с помощью real.(b)
. Обработка цветов RGB немного сложнее, поскольку меняется размерность массива. Один из подходов — использовать включения в Julia:
julia> a = reshape(collect(0.1:0.1:0.6), 3, 2)
3×2 Matrix{Float64}:
0.1 0.4
0.2 0.5
0.3 0.6
julia> c = [RGB(a[1,j], a[2,j], a[3,j]) for j = 1:2]
2-element Array{RGB{Float64},1} with eltype RGB{Float64}:
RGB{Float64}(0.1,0.2,0.3)
RGB{Float64}(0.4,0.5,0.6)
julia> x = [getfield(c[j], i) for i = 1:3, j = 1:2]
3×2 Matrix{Float64}:
0.1 0.4
0.2 0.5
0.3 0.6
Хотя такой подход работает, он не лишен недостатков:
-
Такая реализация предполагает двухмерность
a
; трехмерный массив (представляющий двухмерное цветное изображение) потребует другой реализации. -
Использование
getfield
предполагает, что элементыc
имеют поля, следующие в порядкеr
,g
,b
. Учитывая большое количество различных представлений RGB, поддерживаемых ColorTypes, ни в одном из этих предположений нет полной уверенности. -
Всегда создается копия данных.
Для устранения этих недостатков JuliaImages предоставляет две взаимодополняющие функции представлений: colorview
и channelview
:
julia> colv = colorview(RGB, a)
2-element reinterpret(reshape, RGB{Float64}, ::Matrix{Float64}) with eltype RGB{Float64}:
RGB{Float64}(0.1,0.2,0.3)
RGB{Float64}(0.4,0.5,0.6)
julia> chanv = channelview(c)
3×2 reinterpret(reshape, Float64, ::Array{RGB{Float64},1}) with eltype Float64:
0.1 0.4
0.2 0.5
0.3 0.6
colorview
и channelview
всегда возвращают представление исходного массива.
Использование colorview для создания цветных наложений
Еще одно применение colorview
— объединение нескольких изображений в оттенках серого в одно цветное. Например:
using Colors, Images
r = range(0,stop=1,length=11)
b = range(1,stop=0,length=11)
img1d = colorview(RGB, r, zeroarray, b)
# output
11-element mappedarray(RGB{Float64}, ImageCore.extractchannels, ::StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}}, ::ImageCore.ZeroArray{Float64, 1, Base.OneTo{Int64}}, ::StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}}) with eltype RGB{Float64}:
RGB{Float64}(0.0,0.0,1.0)
RGB{Float64}(0.1,0.0,0.9)
RGB{Float64}(0.2,0.0,0.8)
RGB{Float64}(0.3,0.0,0.7)
RGB{Float64}(0.4,0.0,0.6)
RGB{Float64}(0.5,0.0,0.5)
RGB{Float64}(0.6,0.0,0.4)
RGB{Float64}(0.7,0.0,0.3)
RGB{Float64}(0.8,0.0,0.2)
RGB{Float64}(0.9,0.0,0.1)
RGB{Float64}(1.0,0.0,0.0)
дает (в IJulia) такой результат:
zeroarray
— это специальная константа, которая «расширяется» для возврата эквивалента массива, состоящего из одних нулей, с осями, соответствующими другим входным данным colorview
.
Изменение порядка измерений
Когда цвета разделены на отдельные цветовые измерения, в том или ином коде может предполагаться, что цвет — это последнее (самое медленное) измерение. Преобразование можно выполнить напрямую с помощью функции Julia permutedims
:
julia> pc = permutedims(a, (2,1))
2×3 Matrix{Float64}:
0.1 0.2 0.3
0.4 0.5 0.6
permutedims
явным образом создает новый массив с данными, переупорядоченными в памяти. Нечто подобное можно сделать и в виде представления:
julia> pv = PermutedDimsArray(a, (2,1))
2×3 PermutedDimsArray(::Matrix{Float64}, (2, 1)) with eltype Float64:
0.1 0.2 0.3
0.4 0.5 0.6
Хотя результат выглядит похоже, pv
(в отличие от pc
) размещается в той же области памяти, что и a
; это мнимая перестановка, достигаемая за счет того, что при обращении к отдельным элементам индексы в PermutedDimsArray
переставляются относительно входных индексов.
Следует учитывать, что производительность при этих двух подходах может различаться по причинам, связанным с особенностями работы процессоров и памяти, а не с ограничениями Julia. Если a
имеет большой размер и нужно получить доступ ко всем трем элементам, соответствующим цветовым каналам одного пикселя, вариант с pv
, вероятно, будет более эффективным, поскольку значения расположены рядом в памяти и поэтому, скорее всего, будут находиться в одной строке кэша. И наоборот, если нужно последовательно получить доступ к разным пикселям одного цветового канала, вариант с pc
может оказаться более эффективным (по той же причине).
Дополнение
Иногда сравниваемые изображения могут иметь разные размеры. Создать представления массивов с общими индексами можно с помощью функции paddedviews
:
julia> a1 = reshape([1,2], 2, 1)
2×1 Matrix{Int64}:
1
2
julia> a2 = [1.0,2.0]'
1×2 adjoint(::Vector{Float64}) with eltype Float64:
1.0 2.0
julia> a1p, a2p = paddedviews(0, a1, a2); # 0 — это значение заполнения
julia> a1p
2×2 PaddedView(0, ::Matrix{Int64}, (Base.OneTo(2), Base.OneTo(2))) with eltype Int64:
1 0
2 0
julia> a2p
2×2 PaddedView(0.0, adjoint(::Vector{Float64}), (Base.OneTo(2), Base.OneTo(2))) with eltype Float64:
1.0 2.0
0.0 0.0
Это может быть особенно полезно в сочетании с colorview
для сравнения двух (или более) изображений в оттенках серого. Дополнительные сведения см. в разделе Keeping track of location with unconventional indices.
StackedView
Иногда бывает полезно объединить несколько изображений в одно представление, чтобы далее работать с ними как с массивом.
julia> img1 = reshape(1:8, (2,4))
2×4 reshape(::UnitRange{Int64}, 2, 4) with eltype Int64:
1 3 5 7
2 4 6 8
julia> img2 = reshape(11:18, (2,4))
2×4 reshape(::UnitRange{Int64}, 2, 4) with eltype Int64:
11 13 15 17
12 14 16 18
julia> sv = StackedView(img1, img2)
2×2×4 StackedView{Int64, 3, Tuple{Base.ReshapedArray{Int64, 2, UnitRange{Int64}, Tuple{}}, Base.ReshapedArray{Int64, 2, UnitRange{Int64}, Tuple{}}}}:
[:, :, 1] =
1 2
11 12
[:, :, 2] =
3 4
13 14
[:, :, 3] =
5 6
15 16
[:, :, 4] =
7 8
17 18
julia> imgMatrix = reshape(sv, (2, 8))
2×8 reshape(::StackedView{Int64, 3, Tuple{Base.ReshapedArray{Int64, 2, UnitRange{Int64}, Tuple{}}, Base.ReshapedArray{Int64, 2, UnitRange{Int64}, Tuple{}}}}, 2, 8) with eltype Int64:
1 2 3 4 5 6 7 8
11 12 13 14 15 16 17 18
Составные представления (и краткие сводки)
Когда массив в Julia отображается в виде текста, в начале обычно выводится однострочная сводка с типом массива. Возможно, вы уже заметили, что в JuliaImages используется нетрадиционный синтаксис для обобщения информации об определенных видах массивов. Например, приведенный выше массив pv
имеет следующий тип:
PermutedDimsArray{Float64,2,(2,1),(2,1),Matrix{Float64}}
Однако при отображении такого объекта в строке сводки выводится следующее:
2×3 PermutedDimsArray(::Matrix{Float64}, (2, 1)) with eltype Float64
Это сделано для того, чтобы информацию о типах было удобнее читать.
Основная причина в том, что представления различных типов можно свободно комбинировать, но тип при этом становится довольно длинным. Например, предположим, на диске есть файл с массивом m×n×3×t UInt8
, представляющим собой фильм RGB (t
— это ось времени). Чтобы отобразить его как фильм RGB, можно создать следующее представление массива A
:
julia> A = rand(UInt8, 5, 6, 3, 10);
julia> mov = colorview(RGB, normedview(PermutedDimsArray(A, (3,1,2,4))));
julia> summary(mov)
"5×6×10 reinterpret(reshape, RGB{N0f8}, normedview(N0f8, PermutedDimsArray(::Array{UInt8, 4}, (3, 1, 2, 4)))) with eltype RGB{N0f8}"
julia> typeof(mov)
Base.ReinterpretArray{RGB{N0f8}, 3, N0f8, MappedArrays.MappedArray{N0f8, 4, PermutedDimsArray{UInt8, 4, (3, 1, 2, 4), (2, 3, 1, 4), Array{UInt8, 4}}, ImageCore.var"#41#42"{N0f8}, typeof(reinterpret)}, true}
Хотя использование представлений в JuliaImage почти или вообще не влияет на производительность, иногда типы могут становиться сложными!