Complex trace attributes

Introduction 🎍

In this page, we are going to compute and discuss attributes of complex seismic trace. These attributes are quite useful, even popular, in seismic interpretation.

A complex trace is a mathematical abstraction. In reality, seismic data are represented by real numbers, not complex. Consequently, we need to add something ‘imaginary’ to the data in order to obtain a complex seismic trace. What is this imaginary part? For complex seismic trace, the imaginary part is a result of Hilbert transform applied to the original trace. In frequency domain, Hilbert transform means rotating the phase of signal by 90°. It is fairly intuitive and can be easily computed. This phase-rotated signal is aslo referred to as quadrature trace.

Thus, complex trace \(F(t)\) can be represented by the following equation:

\[ F(t) = f(t) + if^*(t) \]

Here, \(f(t)\) is real trace (a normal, usual seismic trace), \(f^*(t)\) stands for Hilbert transform of \(f(t)\).

It turns out that this combination is vivid and illustrative, and we will show it later. More than that, the amplitude and phase of complex trace describe several important properties of seismic data. They contain details related to subsurface, and that explains their popularity.

Objectives 🎯

  1. Import a seismic trace

  2. Apply Hilbert transform, compute a complex trace

  3. Compute complex trace attributes

  4. Fourier spectrum of a complex trace

Import a trace 📥

For this experiment, I have a real seismic trace in ASCII text format. It can be imported with np.loadtxt.

import numpy as np
from numpy import pi, exp, sin, cos

path = r'.\Data\trc.txt'
time_and_trace = np.loadtxt(path)
time_and_trace[:10]
array([[ 0.00e+00,  0.00e+00],
       [ 1.00e+00, -9.00e-03],
       [ 2.00e+00, -4.10e-02],
       [ 3.00e+00, -9.90e-02],
       [ 4.00e+00, -1.71e-01],
       [ 5.00e+00, -2.39e-01],
       [ 6.00e+00, -2.90e-01],
       [ 7.00e+00, -3.21e-01],
       [ 8.00e+00, -3.43e-01],
       [ 9.00e+00, -3.56e-01]])

The file contains two columns: the first one is time in milliseconds, the second is amplutide. Sample rate for this trace is 1 ms.

t = time_and_trace[:, 0]
trace = time_and_trace[:, 1]

NSamples = len(t)
dt = t[1] - t[0]

Here is the trace:

import matplotlib.pyplot as plt

f, ax = plt.subplots(figsize = [10,2.5])

plt.plot(t, trace)
plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.show()
../../_images/Complex_trace_attr_15_0.png

A complex trace 🪢

Conveniently, scipy library has Hilbert trasform implemented in function scipy.signal.hilbert. It returns a complex trace: its real part equals to the original signal and imaginary part is the quadrature trace.

from scipy.signal import hilbert
complex_trace = hilbert(trace)
quadr_trace = np.imag(complex_trace)
f, ax = plt.subplots(figsize = [10, 2.5])

plt.plot(t, trace, lw = 2, label = 'Seismic trace')
plt.plot(t, quadr_trace, label = 'Quadrature trace')
plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.show()
../../_images/Complex_trace_attr_20_0.png

Thinking of seismic trace and quadrature trace as parts of a complex number, we can plot them in 3D axes:

fig = plt.figure(figsize = [12,12])
ax = plt.axes(projection='3d', azim = -57, elev = 20)

plot_indices_3d = range(220,420)
x = trace[plot_indices_3d]
y = quadr_trace[plot_indices_3d]
tt = t[plot_indices_3d]

xymax = 7

ax.plot3D(tt, x, y, 'k', lw = 2)
ax.plot3D(tt, xymax+0*x, y, 'b', ls='--', lw = 1)
ax.plot3D(tt, x, -xymax+0*y, 'r', ls='--', lw = 1)
ax.plot3D(0*tt + np.min(tt), x, y, 'g', ls='--', lw = 1)

ax.set_xlim(np.min(tt), np.max(tt))
ax.set_ylim(-xymax, xymax)
ax.set_zlim(-xymax, xymax)
ax.set_xlabel(r'Time', labelpad = 14)
ax.set_ylabel('Seismic trace (real)', labelpad = 14)
ax.set_zlabel('Quadrature trace (imaginary)', labelpad = 14)

plt.show()
../../_images/Complex_trace_attr_22_0.png

Complex trace attributes ➿

Instantaneous amplitude

Instantaneous amplitude (or amplitude envelope) can be thought of as a measure of amplitude intensity. According to A. E. Barnes, “At a given time, it represents the magnitude of the sinusoid that best matches the seismic trace in a small window about that time”. Another fact about instantaneous amplitude: at each time sample, it equals the maximum amplitude that can be reached by constant phase rotation of trace, hence the name “envelope”.

Instantaneous amplitude is the absolute value (modulus) of the complex trace:

\[ \mathrm{IA} = |z|=\sqrt{x^2+y^2} \]

where \(x\) is the seismic trace, \(y\) is quadrature trace

inst_amp = np.abs(complex_trace)
f, ax = plt.subplots(figsize = [10, 2.5])

plt.plot(t, trace, label = 'Seismic trace', lw = 1)
plt.plot(t, inst_amp, label = 'Inst. Amp.')
plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.show()
../../_images/Complex_trace_attr_27_0.png

Below is shown a bunch of phase-rotated traces. The name ‘envelope’ becomes clear: it is tangent to the set of traces. Interestingly, negated intantaneous amplitude also bends around the traces, but from below.

from scipy.fftpack import fft, ifft, fftfreq

AS = np.abs(fft(trace))
PS = np.angle(fft(trace))

PS_sh = np.zeros_like(PS)
AS_sh = np.zeros_like(AS)
phase_step = 60

f, ax = plt.subplots(figsize = [10, 2.5])

for i, phshift in enumerate(range(-180, 180, phase_step)):
    PS_sh[1:NSamples//2] = PS[1:NSamples//2] + pi*phshift/180
    PS_sh[NSamples//2+1:] = PS[NSamples//2+1:] - pi*phshift/180
    trc_sh = np.real_if_close(ifft(AS * exp(1j*PS_sh)))
    plt.plot(t, trc_sh, c = '#32466e', lw = 0.5)

plt.plot(t, trace, label = 'Seismic trace')
plt.plot(t, trace, c = '#32466e', lw = 0.5, label = 'Phase-rotated traces')
plt.plot(t, inst_amp, label = 'Inst. Amp.', lw = 3)
plt.plot(t, -1*inst_amp, label = '-1*Inst. Amp.', lw = 3)

plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.xlim(250,450)

plt.show()
../../_images/Complex_trace_attr_29_0.png

Instantaneous phase

Instantaneous phase, by definition, is the phase, or argument, of the complex trace:

\[ \mathrm{IP} = \arg(z) = \arctan{\frac{y}{x}} \]

The instantaneous phase is 0° at peaks, 180° at troughs, ±90° at zero crossings.

inst_phase = np.angle(complex_trace)
f, ax = plt.subplots(figsize = [15, 2.5])

ax.plot(t, trace, label = 'Seismic trace', lw = 1)
ax.plot([], [], c='#96c019', label = 'Inst. Phase')
ax.set_ylabel('Amplitude')
ax.grid(False)
ax.set_ylim(-3,3)
ax.legend(bbox_to_anchor=(1.1,0.5), loc="center left")

ax2 = ax.twinx()
ax2.plot(t, 180*inst_phase/np.pi, c='#96c019')
ax2.set_xlabel('Time, ms')
ax2.set_ylabel('Phase')
ax2.set_yticks(np.arange(-180, 181, 90))
ax2.set_ylim(-210, 210)

ax2.axhline(0, lw=1, c='#333333', zorder = 0)

plt.xlim(250,450)

plt.show()
../../_images/Complex_trace_attr_34_0.png

Unwrapped instantaneous phase

As we can see, due to the \(2\pi\) periodicity of \(\mathrm{arctan}\), phase values are constrained within interval \([ -180°,-180°)\). It causes discontinuities and sawtooth-like appearance of this attribute. For some mathematical purposes, unwrapped instantaneous phase may be more applicable. But when it comes to vusial interpretation of seismic sections, usually wrapped instantaneous phase is preferred.

unwr_inst_phase = np.unwrap(inst_phase)
f, ax = plt.subplots(figsize = [15, 2.5])

ax.plot(t, trace, label = 'Seismic trace', lw = 1)
ax.plot([], [], c='#96c019', label = 'Unwrapped IP')
ax.set_ylabel('Amplitude')
ax.grid(False)
ax.set_ylim(-3,3)
ax.legend(bbox_to_anchor=(1.1,0.5), loc="center left")

ax2 = ax.twinx()
ax2.plot(t, 180*unwr_inst_phase/np.pi, c='#96c019')
ax2.set_xlabel('Time, ms')
ax2.set_ylabel('Phase')

# plt.xlim(200,450)

plt.show()
../../_images/Complex_trace_attr_37_0.png

Cosine of instantaneous phase

cos_inst_phase = np.cos(inst_phase)
f, ax = plt.subplots(figsize = [10, 2.5])

ax.plot(t, trace, label = 'Seismic trace', lw = 1)
ax.plot([], [], c='#96c019', label = 'cos(IP)')
ax.set_ylabel('Amplitude')
ax.grid(False)
ax.set_ylim(-3, 3)
ax.legend(bbox_to_anchor=(1.07,0.5), loc="center left")

ax2 = ax.twinx()
ax2.plot(t, cos_inst_phase, c='#96c019')
ax2.set_xlabel('Time, ms')
ax2.set_ylabel('cosine of IP')
ax2.set_yticks(np.arange(-2, 3))
ax2.set_ylim(-2, 2)

ax2.axhline(0, lw=1, c='#333333', zorder = 0)
plt.xlim(200,450)

plt.show()
../../_images/Complex_trace_attr_40_0.png

Extrema of this attribute roughly coincide with trace extrema, except their amplitude is ±1. We can think of this attribute as a special type of gain equalizer.

Product of envelope and cosine of instantaneous phase equals to the initial seismic trace.

f, ax = plt.subplots(figsize = [10, 2.5])

plt.plot(t, trace, label = 'Seismic trace', lw = 2)
plt.plot(t, inst_amp, label = 'Inst. Amp.', lw = 1)
plt.plot(t, cos_inst_phase, lw = 1, label = 'cos IP')
plt.plot(t, cos_inst_phase * inst_amp, ls = ':', lw = 3, label = 'IA$\cdot$cos(IP)', c = 'orange')
plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.xlim(200, 450)
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.show()
../../_images/Complex_trace_attr_42_0.png

This implies a curious way of phase rotation. We can multiply a trace by cosine of shifted instantaneous phase!

Let’s test with 60° (π/3) rotation:

phase_shifted_trace = np.cos(inst_phase + np.pi/3) * inst_amp
f, ax = plt.subplots(figsize = [10, 2.5])

plt.plot(t, trace, label = 'Seismic trace', lw = 2)
plt.plot(t, inst_amp, label = 'Inst. Amp.', lw = 1)

plt.plot(t, phase_shifted_trace, lw = 2, label = '60°-rotated')
plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.xlim(200, 450)
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.show()
../../_images/Complex_trace_attr_45_0.png

Instantaneous frequency

Instantaneous frequency is the scaled derivative of instantaneous phase.

\[ \mathrm{IF} = \frac{1}{2\pi} \frac{d(\mathrm{IP})}{dt} \]

Phase unwrapping should be performed prior to calculating the instantaneous frequency. Without it, the derivative is spiky and instantaneous frequency is not so illustrative.

Another fact is that discrete derivative np.diff returns an array that is one sample shorter than the input data. Thus, to preserve the length of trace, we need to add a zero value to either edge of array.

inst_freq = np.zeros_like(trace)
inst_freq[1:] = (1/(2*pi)) * (np.diff(unwr_inst_phase)/(dt/1000))
f, ax = plt.subplots(figsize = [10, 2.5])

ax.plot(t, trace, label = 'Seismic trace', lw = 1)
ax.plot([], [], c='#96c019', label = 'Inst. frequency')
ax.set_ylabel('Amplitude')
ax.grid(False)
ax.set_ylim(-3, 3)
ax.legend(bbox_to_anchor=(1.09,0.5), loc="center left")

ax2 = ax.twinx()
ax2.plot(t, inst_freq, c='#96c019')
ax2.set_xlabel('Time, ms')
ax2.set_ylabel('Freq, Hz')
# ax2.set_yticks(np.arange(-2, 3))
ax2.set_ylim(-20, 120)
# plt.xlim(200, 450)

ax2.axhline(0, lw=1, c='#333333', zorder = 0)

plt.show()
../../_images/Complex_trace_attr_49_0.png

Instantaneous frequency is corrupted by large spikes which can be positive and negative. As much as they obstruct interpretation, they can sometimes be used as an indicator of stratigraphic discontinuities.

Other attributes

There are plenty of derived attributes of complex trace, such as sweetness, instantaneous bandwidth, apparent polarity and so on. If interested, we’d recommend a description in [Barnes, 2016].

Appendix A: Complex trace spectrum

What does the Fourier spectrum of the complex trace look like?

from scipy.fftpack import fft, ifft, fftfreq, fftshift
# trace spectrum
T_S = fft(trace)
T_AS = np.abs(T_S)
T_PS = np.angle(T_S)

# quadrature trace spectrum
QT_S = fft(quadr_trace)
QT_AS = np.abs(QT_S)
QT_PS = np.angle(QT_S)

# complex trace spectrum
CT_S = fft(complex_trace)
CT_AS = np.abs(CT_S)
CT_PS = np.angle(CT_S)

freq = fftfreq(NSamples, dt*0.001)
f, [ax0_0, ax1_0, ax2_0] = plt.subplots(3,1, figsize = [10, 8], sharex = True)

[ax0_1, ax1_1, ax2_1] = [axi.twinx() for axi in [ax0_0, ax1_0, ax2_0]]


ax0_0.set_title('Trace spectrum')
ax0_0.plot(fftshift(freq), fftshift(T_AS))
ax0_1.plot(fftshift(freq), fftshift(T_PS), c='#96c019', lw = 1)


ax1_0.set_title('Quadrature trace spectrum')
ax1_0.plot(fftshift(freq), fftshift(QT_AS))
ax1_1.plot(fftshift(freq), fftshift(QT_PS), c='#96c019', lw = 1)

ax1_0.set_ylabel('Amplitude')
ax1_1.set_ylabel('Phase')

ax2_0.set_title('Сomplex trace spectrum')
ax2_0.plot(fftshift(freq), fftshift(CT_AS))
ax2_1.plot(fftshift(freq), fftshift(CT_PS), c='#96c019', lw = 1)

[axi.axhline(0, lw=1, c='#333333', zorder = 0) for axi in [ax0_0, ax1_0, ax2_0]]
ax2_0.set_xlabel('Frequency, Hz')
ax0_0.set_xlim(-150,150);
../../_images/Complex_trace_attr_57_0.png

What do we see?

  1. Since a complex trace is complex-valued, its spectra sould be (and indeed is) asymmetric

  2. What is more, it has no negative frequencies (and their phase is therefore meaningless)

  3. Its positive part equals to the positive part of the initial trace spectrum

Appendix B: Phase acceleration

As an experiment, it may be interesting to perform ‘phase acceleration’, or ‘phase multiplication’. It is an artificial technique that is sometimes used to increase number of peaks and troughs in data. As an addition, this broadens the spectrum. More informaton can be found in [Stark, 2009], [Liang, 2017]

Recall the seismic trace can be represented as:

\[ f(t) = \mathrm{IA} \cdot \cos(\mathrm{IP}) $ \]

n-times accelerated trace is created as follows:

\[ f_n (t) = \mathrm{IA} \cdot \cos(n \cdotp \mathrm{IP}) \]

If the unwrapped version of phase is used, N can even be non-integer or less than 1.

n_acc = [2, 3, 4, 0.5, 1.2345]

f, axs = plt.subplots(len(n_acc), 2, figsize = [12, 2.5*len(n_acc)], sharex = 'col', gridspec_kw = {'width_ratios': [5, 2], 'wspace':0.05})

for iax, axi in enumerate(axs):

    ht, = axi[0].plot(t, trace, lw = 2)
    ntrace = np.cos(n_acc[iax]*unwr_inst_phase) * inst_amp
    hnt, = axi[0].plot(t, ntrace, lw = 1.5)
    hia, = axi[0].plot(t, inst_amp, lw = 1, ls = '--')
    hia2, = axi[0].plot(t, -inst_amp, lw = 1, ls = '--', c='#96c019')
    
    axi[0].set_xlim(200, 450)
    axi[0].axhline(0, lw=1, c='#333333', zorder = 0)
    
    axi[0].annotate('N = {0}'.format(n_acc[iax]), (0.015, 0.91),  xycoords = 'axes fraction', ha = 'left', va = 'top',
        bbox={'facecolor': 'w', 'alpha': 0.5, 'boxstyle': 'Round'})
    
    
    hst, = axi[1].plot(fftshift(freq), fftshift(np.abs(fft(trace))), lw = 2)
    hsht, = axi[1].plot(fftshift(freq), fftshift(np.abs(fft(ntrace))), lw = 1.5)
    axi[1].axhline(0, lw=1, c='#333333', zorder = 0)
    axi[1].set_xlim([0, 200])
    axi[1].yaxis.set_ticklabels([])
    
axi[1].set_xlabel('Frequency, Hz')
axi[0].set_xlabel('Time, ms')
    
    
f.legend([ht, hia, hnt],
         ['Seismic trace', '±Inst. Amp.', 'IA$\cdot$cos(N$\cdot$IP)'],
         loc="lower center",
         bbox_to_anchor=(0.15, 0.022, 0.5, 0.05),
         ncol = 3
         )

f.legend([hst, hsht],
         ['Seismic trace', 'IA$\cdot$cos(N$\cdot$IP)'],
         loc="lower center",
         bbox_to_anchor=(0.55, 0.0, 0.5, 0.05),
         ncol = 1
         )

plt.show()
../../_images/Complex_trace_attr_61_0.png

We see that the trace envelope stays unchanged, but the frequencies change significantly.

For integer values of N, locations of the initial trace maxima correspond to some accelerated trace maxima.

A very interesting phenomenon can be observed if we sum together accelerated traces for several values of N. Let’s try for N from 1 to 6.

n_acc = [1, 2, 3, 4, 5, 6]

ntrace = np.zeros_like(trace)

for i, nn in enumerate(n_acc):
    ntrace = ntrace + np.cos(nn * unwr_inst_phase) * inst_amp

ntrace = ntrace / len(n_acc)  # normalization

f, ax = plt.subplots(figsize = [10, 2.5])
    
plt.plot(t, trace, label = 'Seismic trace', lw = 2)
plt.plot(t, ntrace, lw = 1, label = 'Accel. N = 1 to 6')

plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.xlim(200, 450)
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.show()
../../_images/Complex_trace_attr_63_0.png

As we can see, this is an attribute that ‘thins’ the positive extrema of the trace.

Same technique can be utilized to ‘thin’ the negative extrema. To achieve that, the trace first should be negated (we can do that by shifting the phase by π), and then the N-accelerated trace should be inverted back.

neg_ntrace = np.zeros_like(trace)

for i, nn in enumerate(n_acc):
    neg_ntrace = neg_ntrace + np.cos(nn * (unwr_inst_phase + np.pi)) * inst_amp

neg_ntrace = -1*neg_ntrace / len(n_acc) # normalization
    
f, ax = plt.subplots(figsize = [10, 2.5])

plt.plot(t, trace, label = 'Seismic trace', lw = 2)
plt.plot(t, neg_ntrace, lw = 1, label = 'Accel.(-) N = 1 to 6')

plt.xlabel('Time, ms')
plt.ylabel('Amplitude')
plt.xlim(200, 450)
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)
plt.show()
../../_images/Complex_trace_attr_65_0.png

Sum of ‘positive’ and ‘negative’ N-phase-accelerated traces is supposed to have a higher visible ‘resolution’ and a broader bandwidth than the original trace.

f, ax = plt.subplots(figsize = [10, 2.5])

plt.plot(t, trace, label = 'Seismic trace', lw = 2)
plt.plot(t, ntrace + neg_ntrace, lw = 1, label = 'Accel.Sum N = 1 to 6')

plt.xlabel('Time, ms')
plt.ylabel('Amplitude')

plt.xlim(200, 450)
plt.legend(bbox_to_anchor=(1.00,0.5), loc="center left")
plt.axhline(0, lw=1, c='#333333', zorder = 0)

plt.show()
../../_images/Complex_trace_attr_67_0.png
f, axs = plt.subplots(1, 2, figsize = [12, 2.5], gridspec_kw = {'width_ratios': [5, 2], 'wspace': 0.06})

axs[0].plot(t, trace, lw = 1)
axs[0].plot(t, ntrace+neg_ntrace, lw = 1.5)


axs[0].set_xlim(200, 450)
axs[0].axhline(0, lw=1, c='#333333', zorder = 0)
axs[0].set_xlabel('Time, ms')
axs[0].set_ylabel('Amplitude')

axs[1].plot(fftshift(freq), fftshift(np.abs(fft(trace))), lw = 1)
axs[1].plot(fftshift(freq), 3*fftshift(np.abs(fft(ntrace-neg_ntrace))), lw = 1.5)
axs[1].set_xlim([0, 200])
axs[1].yaxis.set_ticklabels([])
axs[1].set_xlabel('Frequency, Hz')
axs[1].axhline(0, lw=1, c='#333333', zorder = 0)

plt.show()
../../_images/Complex_trace_attr_68_0.png
  1. The spectrum became much broader;

  2. This has no physical meaning and makes no geological sense;

  3. However, this attribute might highlight some features on seismic sections;

  4. This is pretty close to highlighting the local extrema.

Main question ❔

The main question (to me it stays unanswered) is: why this works? Why do we get something that curls up so cool by simply adding 90°-rotated trace as an imaginary part to the real trace? Why is it informative? Are there other such transfomations?