Se eu entendi seu método 1 corretamente, com ele, se você usasse uma região circularmente simétrica e fizesse a rotação no centro da região, eliminaria a dependência da região no ângulo de rotação e obteria uma comparação mais justa pela função de mérito entre diferentes ângulos de rotação. Vou sugerir um método que é essencialmente equivalente a isso, mas usa a imagem completa e não requer rotação repetida da imagem, e incluirá a filtragem passa-baixo para remover a anisotropia da grade de pixels e o denoising.
Gradiente de imagem filtrada isotropicamente passa-baixa
Primeiro, vamos calcular um vetor de gradiente local em cada pixel para o canal de cor verde na imagem de amostra em tamanho real.
Eu derivei núcleos de diferenciação horizontal e vertical diferenciando a resposta de impulso no espaço contínuo de um filtro passa-baixa ideal com uma resposta de frequência circular plana que remove o efeito da escolha dos eixos da imagem, garantindo que não haja nível de detalhe diferente na diagonal comparado horizontal ou vertical, amostrando a função resultante e aplicando uma janela de cosseno girada:
hx[x,y]=⎧⎩⎨⎪⎪0−ω2cxJ2(ωcx2+y2−−−−−−√)2π(x2+y2)if x=y=0,otherwise,hy[x,y]=⎧⎩⎨⎪⎪0−ω2cyJ2(ωcx2+y2−−−−−−√)2π(x2+y2)if x=y=0,otherwise,(1)
Onde J2 é uma função de Bessel de 2ª ordem do primeiro tipo e ωcé a frequência de corte em radianos. Fonte Python (não possui os sinais de menos da Eq. 1):
import matplotlib.pyplot as plt
import scipy
import scipy.special
import numpy as np
def rotatedCosineWindow(N): # N = horizontal size of the targeted kernel, also its vertical size, must be odd.
return np.fromfunction(lambda y, x: np.maximum(np.cos(np.pi/2*np.sqrt(((x - (N - 1)/2)/((N - 1)/2 + 1))**2 + ((y - (N - 1)/2)/((N - 1)/2 + 1))**2)), 0), [N, N])
def circularLowpassKernelX(omega_c, N): # omega = cutoff frequency in radians (pi is max), N = horizontal size of the kernel, also its vertical size, must be odd.
kernel = np.fromfunction(lambda y, x: omega_c**2*(x - (N - 1)/2)*scipy.special.jv(2, omega_c*np.sqrt((x - (N - 1)/2)**2 + (y - (N - 1)/2)**2))/(2*np.pi*((x - (N - 1)/2)**2 + (y - (N - 1)/2)**2)), [N, N])
kernel[(N - 1)//2, (N - 1)//2] = 0
return kernel
def circularLowpassKernelY(omega_c, N): # omega = cutoff frequency in radians (pi is max), N = horizontal size of the kernel, also its vertical size, must be odd.
kernel = np.fromfunction(lambda y, x: omega_c**2*(y - (N - 1)/2)*scipy.special.jv(2, omega_c*np.sqrt((x - (N - 1)/2)**2 + (y - (N - 1)/2)**2))/(2*np.pi*((x - (N - 1)/2)**2 + (y - (N - 1)/2)**2)), [N, N])
kernel[(N - 1)//2, (N - 1)//2] = 0
return kernel
N = 41 # Horizontal size of the kernel, also its vertical size. Must be odd.
window = rotatedCosineWindow(N)
# Optional window function plot
#plt.imshow(window, vmin=-np.max(window), vmax=np.max(window), cmap='bwr')
#plt.colorbar()
#plt.show()
omega_c = np.pi/4 # Cutoff frequency in radians <= pi
kernelX = circularLowpassKernelX(omega_c, N)*window
kernelY = circularLowpassKernelY(omega_c, N)*window
# Optional kernel plot
#plt.imshow(kernelX, vmin=-np.max(kernelX), vmax=np.max(kernelX), cmap='bwr')
#plt.colorbar()
#plt.show()
Figura 1. Janela cosseno girada em 2-d.
Figura 2. Núcleos horizontais de diferenciação isotrópica de passa-baixa, para diferentes frequências de corte ωcdefinições. Topo omega_c = np.pi
:, meio:omega_c = np.pi/4
, bottom: omega_c = np.pi/16
. O sinal de menos da Eq. 1 foi deixado de fora. Os kernels verticais têm a mesma aparência, mas foram girados 90 graus. Soma ponderada dos núcleos horizontal e vertical, com pesoscos(ϕ) e sin(ϕ), respectivamente, fornece um núcleo de análise do mesmo tipo para ângulo de gradiente ϕ.
A diferenciação da resposta ao impulso não afeta a largura de banda, como pode ser visto pela sua transformada rápida de Fourier (FFT) 2-d, em Python:
# Optional FFT plot
absF = np.abs(np.fft.fftshift(np.fft.fft2(circularLowpassKernelX(np.pi, N)*window)))
plt.imshow(absF, vmin=0, vmax=np.max(absF), cmap='Greys', extent=[-np.pi, np.pi, -np.pi, np.pi])
plt.colorbar()
plt.show()
Figura 3. Magnitude da 2-d FFT de hx. No domínio da frequência, a diferenciação aparece como multiplicação da banda de passagem circular plana porωxe por uma mudança de fase de 90 graus que não é visível na magnitude.
Para fazer a convolução do canal verde e coletar um histograma de vetor gradiente bidimensional, para inspeção visual, em Python:
import scipy.ndimage
img = plt.imread('sample.tif').astype(float)
X = scipy.ndimage.convolve(img[:,:,1], kernelX)[(N - 1)//2:-(N - 1)//2, (N - 1)//2:-(N - 1)//2] # Green channel only
Y = scipy.ndimage.convolve(img[:,:,1], kernelY)[(N - 1)//2:-(N - 1)//2, (N - 1)//2:-(N - 1)//2] # ...
# Optional 2-d histogram
#hist2d, xEdges, yEdges = np.histogram2d(X.flatten(), Y.flatten(), bins=199)
#plt.imshow(hist2d**(1/2.2), vmin=0, cmap='Greys')
#plt.show()
#plt.imsave('hist2d.png', plt.cm.Greys(plt.Normalize(vmin=0, vmax=hist2d.max()**(1/2.2))(hist2d**(1/2.2)))) # To save the histogram image
#plt.imsave('histkey.png', plt.cm.Greys(np.repeat([(np.arange(200)/199)**(1/2.2)], 16, 0)))
Isso também recorta os dados, descartando (N - 1)//2
pixels de cada borda que foram contaminados pelo limite retangular da imagem, antes da análise do histograma.
π
π2
π4
π8
π16
π32
π64
-0
Figura 4. Histogramas 2D de vetores de gradiente, para diferentes frequências de corte de filtro passa-baixo ωcdefinições. Em ordem: em primeiro lugar, com N=41
: omega_c = np.pi
, omega_c = np.pi/2
, omega_c = np.pi/4
(o mesmo que no pitão listagem), omega_c = np.pi/8
, omega_c = np.pi/16
,, em seguida,: N=81
: omega_c = np.pi/32
, N=161
: omega_c = np.pi/64
. A denoising por filtragem passa-baixa aguça as orientações de gradiente de arestas de rastreamento do circuito no histograma.
Direção média circular ponderada em comprimento de vetor
Existe o método Yamartino de encontrar a direção "média" do vento a partir de várias amostras de vetores de vento em uma passagem pelas amostras. Baseia-se na média das quantidades circulares , que é calculada como o deslocamento de um cosseno que é uma soma de cossenos cada um deslocada por uma quantidade circular de período2π. Podemos usar uma versão ponderada em comprimento vetorial do mesmo método, mas primeiro precisamos agrupar todas as direções com módulo igualπ/2. Podemos fazer isso multiplicando o ângulo de cada vetor de gradiente[Xk,Yk] por 4, usando uma representação numérica complexa:
Zk=(Xk+Yki)4X2k+Y2k−−−−−−−√3=X4k−6X2kY2k+Y4k+(4X3kYk−4XkY3k)iX2k+Y2k−−−−−−−√3,(2)
satisfatório |Zk|=X2k+Y2k−−−−−−−√ e depois interpretando que as fases da Zk de −π para π representam ângulos de −π/4 para π/4, dividindo a fase média circular calculada por 4:
ϕ=14atan2(∑kIm(Zk),∑kRe(Zk))(3)
Onde ϕ é a orientação estimada da imagem.
A qualidade da estimativa pode ser avaliada fazendo outra passagem pelos dados e calculando a distância circular quadrada ponderada média ,MSCD, entre fases dos números complexos Zk e a fase média circular estimada 4ϕcom |Zk| como o peso:
MSCD=∑k|Zk|(1−cos(4ϕ−atan2(Im(Zk),Re(Zk))))∑k|Zk|=∑k|Zk|2((cos(4ϕ)−Re(Zk)|Zk|)2+(sin(4ϕ)−Im(Zk)|Zk|)2)∑k|Zk|=∑k(|Zk|−Re(Zk)cos(4ϕ)−Im(Zk)sin(4ϕ))∑k|Zk|,(4)
que foi minimizado por ϕcalculado por Eq. 3. Em Python:
absZ = np.sqrt(X**2 + Y**2)
reZ = (X**4 - 6*X**2*Y**2 + Y**4)/absZ**3
imZ = (4*X**3*Y - 4*X*Y**3)/absZ**3
phi = np.arctan2(np.sum(imZ), np.sum(reZ))/4
sumWeighted = np.sum(absZ - reZ*np.cos(4*phi) - imZ*np.sin(4*phi))
sumAbsZ = np.sum(absZ)
mscd = sumWeighted/sumAbsZ
print("rotate", -phi*180/np.pi, "deg, RMSCD =", np.arccos(1 - mscd)/4*180/np.pi, "deg equivalent (weight = length)")
Com base nas minhas mpmath
experiências (não mostradas), acho que não ficaremos sem precisão numérica, mesmo para imagens muito grandes. Para diferentes configurações de filtro (anotadas), as saídas são, conforme relatadas entre -45 e 45 graus:
rotate 32.29809399495655 deg, RMSCD = 17.057059965741338 deg equivalent (omega_c = np.pi)
rotate 32.07672617150525 deg, RMSCD = 16.699056648843566 deg equivalent (omega_c = np.pi/2)
rotate 32.13115293914797 deg, RMSCD = 15.217534399922902 deg equivalent (omega_c = np.pi/4, same as in the Python listing)
rotate 32.18444156018288 deg, RMSCD = 14.239347706786056 deg equivalent (omega_c = np.pi/8)
rotate 32.23705383489169 deg, RMSCD = 13.63694582160468 deg equivalent (omega_c = np.pi/16)
A filtragem passa-baixa forte parece útil, reduzindo o ângulo equivalente da distância quadrada média da raiz (RMSCD) calculado como acos( 1 - MSCD ). Sem a janela de cosseno rotacionada em 2-d, alguns dos resultados seriam desativados em um grau mais ou menos (não mostrado), o que significa que é importante executar a janela apropriada dos filtros de análise. O ângulo equivalente ao RMSCD não é diretamente uma estimativa do erro na estimativa do ângulo, que deve ser muito menor.
Função alternativa de peso quadrado
Vamos tentar o quadrado do comprimento do vetor como uma função de peso alternativa:
Zk=(Xk+YkEu)4X2k+Y2k-------√2=X4k- 6X2kY2k+Y4k+ ( 4X3kYk- 4XkY3k) iX2k+Y2k,(5)
Em Python:
absZ_alt = X**2 + Y**2
reZ_alt = (X**4 - 6*X**2*Y**2 + Y**4)/absZ_alt
imZ_alt = (4*X**3*Y - 4*X*Y**3)/absZ_alt
phi_alt = np.arctan2(np.sum(imZ_alt), np.sum(reZ_alt))/4
sumWeighted_alt = np.sum(absZ_alt - reZ_alt*np.cos(4*phi_alt) - imZ_alt*np.sin(4*phi_alt))
sumAbsZ_alt = np.sum(absZ_alt)
mscd_alt = sumWeighted_alt/sumAbsZ_alt
print("rotate", -phi_alt*180/np.pi, "deg, RMSCD =", np.arccos(1 - mscd_alt)/4*180/np.pi, "deg equivalent (weight = length^2)")
O peso do comprimento quadrado reduz o ângulo equivalente do RMSCD em cerca de um grau:
rotate 32.264713568426764 deg, RMSCD = 16.06582418749094 deg equivalent (weight = length^2, omega_c = np.pi, N = 41)
rotate 32.03693157762725 deg, RMSCD = 15.839593856962486 deg equivalent (weight = length^2, omega_c = np.pi/2, N = 41)
rotate 32.11471435914187 deg, RMSCD = 14.315371970649874 deg equivalent (weight = length^2, omega_c = np.pi/4, N = 41)
rotate 32.16968341455537 deg, RMSCD = 13.624896827482049 deg equivalent (weight = length^2, omega_c = np.pi/8, N = 41)
rotate 32.22062839958777 deg, RMSCD = 12.495324176281466 deg equivalent (weight = length^2, omega_c = np.pi/16, N = 41)
rotate 32.22385477783647 deg, RMSCD = 13.629915935941973 deg equivalent (weight = length^2, omega_c = np.pi/32, N = 81)
rotate 32.284350817263906 deg, RMSCD = 12.308297934977746 deg equivalent (weight = length^2, omega_c = np.pi/64, N = 161)
Parece uma função de peso um pouco melhor. Eu adicionei também pontos de corteωc= π/ 32 e ωc= π/ 64. Eles usam maior N
resultando em um corte diferente da imagem e não em valores MSCD estritamente comparáveis.
Histograma 1-d
O benefício da função de peso quadrado é mais aparente com um histograma ponderado em 1 d de Zkfases. Script Python:
# Optional histogram
hist_plain, bin_edges = np.histogram(np.arctan2(imZ, reZ), weights=np.ones(absZ.shape)/absZ.size, bins=900)
hist, bin_edges = np.histogram(np.arctan2(imZ, reZ), weights=absZ/np.sum(absZ), bins=900)
hist_alt, bin_edges = np.histogram(np.arctan2(imZ_alt, reZ_alt), weights=absZ_alt/np.sum(absZ_alt), bins=900)
plt.plot((bin_edges[:-1]+(bin_edges[1]-bin_edges[0]))*45/np.pi, hist_plain, "black")
plt.plot((bin_edges[:-1]+(bin_edges[1]-bin_edges[0]))*45/np.pi, hist, "red")
plt.plot((bin_edges[:-1]+(bin_edges[1]-bin_edges[0]))*45/np.pi, hist_alt, "blue")
plt.xlabel("angle (degrees)")
plt.show()
Figura 5. Histograma ponderado interpolado linearmente de ângulos de vetor gradiente, envolvidos em - π/ 4…π/ 4e ponderado por (em ordem de baixo para cima no pico): sem ponderação (preto), comprimento do vetor gradiente (vermelho), quadrado do comprimento do vetor gradiente (azul). A largura da bandeja é de 0,1 graus. O ponto de corte do filtro era o omega_c = np.pi/4
mesmo da listagem do Python. A figura de baixo é ampliada nos picos.
Matemática direcionável do filtro
Vimos que a abordagem funciona, mas seria bom ter um melhor entendimento matemático. ox e yrespostas de impulso do filtro de diferenciação fornecidas pela Eq. 1 pode ser entendido como as funções básicas para formar a resposta de impulso de um filtro de diferenciação direcionável que é amostrado a partir de uma rotação do lado direito da equação parahx[ x , y](Eq. 1). Isso é mais facilmente visto pela conversão da Eq. 1 a coordenadas polares:
hx( r , θ ) =hx[ r cos( θ ) , r sin( θ ) ]hy( r , θ ) =hy[ r cos( θ ) , r sin( θ ) ]f( r )=⎧⎩⎨0 0-ω2cr cos( θ )J2(ωcr )2 πr2se r = 0 ,de outra forma=cos(θ)f(r),=⎧⎩⎨0−ω2crsin(θ)J2(ωcr)2πr2if r=0,otherwise=sin(θ)f(r),=⎧⎩⎨0−ω2crJ2(ωcr)2πr2if r=0,otherwise,(6)
onde as respostas de impulso do filtro de diferenciação horizontal e vertical têm a mesma função de fator radial f(r). Qualquer versão giradah(r,θ,ϕ) do hx(r,θ) pelo ângulo de direção ϕ é obtido por:
h(r,θ,ϕ)=hx(r,θ−ϕ)=cos(θ−ϕ)f(r)(7)
A idéia era que o kernel direcionado h(r,θ,ϕ) pode ser construído como uma soma ponderada de hx(r,θ) e hx(r,θ)com cos(ϕ) e sin(ϕ) como pesos, e esse é realmente o caso:
cos(ϕ)hx(r,θ)+sin(ϕ)hy(r,θ)=cos(ϕ)cos(θ)f(r)+sin(ϕ)sin(θ)f(r)=cos(θ−ϕ)f(r)=h(r,θ,ϕ).(8)
We will arrive at an equivalent conclusion if we think of the isotropically low-pass filtered signal as the input signal and construct a partial derivative operator with respect to the first of rotated coordinates xϕ, yϕ rotated by angle ϕ from coordinates x, y. (Derivation can be considered a linear-time-invariant system.) We have:
x=cos(ϕ)xϕ−sin(ϕ)yϕ,y=sin(ϕ)xϕ+cos(ϕ)yϕ(9)
Using the chain rule for partial derivatives, the partial derivative operator with respect to xϕ can be expressed as a cosine and sine weighted sum of partial derivatives with respect to x and y:
∂∂xϕ=∂x∂xϕ∂∂x+∂y∂xϕ∂∂y=∂(cos(ϕ)xϕ−sin(ϕ)yϕ)∂xϕ∂∂x+∂(sin(ϕ)xϕ+cos(ϕ)yϕ)∂xϕ∂∂y=cos(ϕ)∂∂x+sin(ϕ)∂∂y(10)
A question that remains to be explored is how a suitably weighted circular mean of gradient vector angles is related to the angle ϕ of in some way the "most activated" steered differentiation filter.
Possible improvements
To possibly improve results further, the gradient can be calculated also for the red and blue color channels, to be included as additional data in the "average" calculation.
I have in mind possible extensions of this method:
1) Use a larger set of analysis filter kernels and detect edges rather than detecting gradients. This needs to be carefully crafted so that edges in all directions are treated equally, that is, an edge detector for any angle should be obtainable by a weighted sum of orthogonal kernels. A set of suitable kernels can (I think) be obtained by applying the differential operators of Eq. 11, Fig. 6 (see also my Mathematics Stack Exchange post) on the continuous-space impulse response of a circularly symmetric low-pass filter.
limh→0∑4N+1N=0(−1)nf(x+hcos(2πn4N+2),y+hsin(2πn4N+2))h2N+1,limh→0∑4N+1N=0(−1)nf(x+hsin(2πn4N+2),y+hcos(2πn4N+2))h2N+1(11)
Figure 6. Dirac delta relative locations in differential operators for construction of higher-order edge detectors.
2) The calculation of a (weighted) mean of circular quantities can be understood as summing of cosines of the same frequency shifted by samples of the quantity (and scaled by the weight), and finding the peak of the resulting function. If similarly shifted and scaled harmonics of the shifted cosine, with carefully chosen relative amplitudes, are added to the mix, forming a sharper smoothing kernel, then multiple peaks may appear in the total sum and the peak with the largest value can be reported. With a suitable mixture of harmonics, that would give a kind of local average that largely ignores outliers away from the main peak of the distribution.
Alternative approaches
It would also be possible to convolve the image by angle ϕ and angle ϕ+π/2 rotated "long edge" kernels, and to calculate the mean square of the pixels of the two convolved images. The angle ϕ that maximizes the mean square would be reported. This approach might give a good final refinement for the image orientation finding, because it is risky to search the complete angle ϕ space at large steps.
Another approach is non-local methods, like cross-correlating distant similar regions, applicable if you know that there are long horizontal or vertical traces, or features that repeat many times horizontally or vertically.