forked from sunpy/sunkit-image
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtrace.py
More file actions
585 lines (471 loc) · 19.4 KB
/
Copy pathtrace.py
File metadata and controls
585 lines (471 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
import numpy as np
from scipy import interpolate
from sunkit_image.utils.decorators import accept_array_or_map
__all__ = [
"bandpass_filter",
"occult2",
"smooth",
]
@accept_array_or_map(arg_name="image", output_to_map=False)
def occult2(image, nsm1, rmin, lmin, nstruc, ngap, qthresh1, qthresh2):
"""
Implements the Oriented Coronal CUrved Loop Tracing (OCCULT-2) algorithm
for loop tracing in images.
Parameters
----------
image : `numpy.ndarray`, `sunpy.map.GenericMap`
Image in which loops are to be detected.
nsm1 : `int`
Low pass filter boxcar smoothing constant.
rmin : `int`
The minimum radius of curvature of the loop to be detected in pixels.
lmin : `int`
The length of the smallest loop to be detected in pixels.
nstruc : `int`
Maximum limit of traced structures.
ngap : `int`
Number of pixels in the loop below the flux threshold.
qthresh1 : `float`
The ratio of image base flux and median flux. All the pixels in the image below
``qthresh1 * median`` intensity value are made to zero before tracing the loops.
qthresh2 : `float`
The factor which determines noise in the image. All the intensity values between
``qthresh2 * median`` are considered to be noise. The median for noise is chosen
after the base level is fixed.
Returns
-------
`list`
A list of all loop where each element is itself a list of points containing
``x`` and ``y`` pixel coordinates for each point.
References
----------
* Markus J. Aschwanden, Bart De Pontieu, Eugene A. Katrukha.
Optimization of Curvi-Linear Tracing Applied to Solar Physics and Biophysics.
Entropy, vol. 15, issue 8, pp. 3007-3030
https://doi.org/10.3390/e15083007
"""
image = image.astype(np.float32)
# Image is transposed because IDL works column major and python is row major. This is done
# so that the python and the IDL codes look similar
image = image.T
# Defining all the other parameters as the IDL one.
# The maximum number of loops that can be detected
nloopmax = 10000
# The maximum number of points in a loop
npmax = 2000
# High pass filter boxcar window size
nsm2 = nsm1 + 2
# The length of the tracing curved element
nlen = rmin
wid = max(nsm2 // 2 - 1, 1)
# BASE LEVEL: Removing the points below the base level
zmed = np.median(image[image > 0])
image = np.where(image > (zmed * qthresh1), image, zmed * qthresh1)
# BANDPASS FILTER
image2 = bandpass_filter(image, nsm1, nsm2)
nx, ny = image2.shape
# ERASE BOUNDARIES ZONES (SMOOTHING EFFECTS)
image2[:, 0:nsm2] = 0.0
image2[:, ny - nsm2 :] = 0.0
image2[0:nsm2, :] = 0.0
image2[nx - nsm2 :, :] = 0.0
if not np.count_nonzero(image2):
msg = (
"The filter size is very large compared to the size of the image."
" The entire image zeros out while smoothing the image edges after filtering."
)
raise RuntimeError(msg)
# NOISE THRESHOLD
zmed = np.median(image2[image2 > 0])
thresh = zmed * qthresh2
# Defines the current number of loop being traced
iloop = 0
# The image with intensity less than zero removed
residual = np.where(image2 > 0, image2, 0)
# Creating the structure in which the loops will be finally stored
loops = []
for _ in range(nstruc):
# Loop tracing begins at maximum flux position
zstart = residual.max()
# If maximum flux is less than noise threshold tracing stops
if zstart <= thresh: # goto: end_trace
break
# Points where the maximum flux is detected
max_coords = np.where(residual == zstart)
istart, jstart = max_coords[0][0], max_coords[1][0]
# TRACING LOOP STRUCTURE STEPWISE
# The point number in the current loop being traced
ip = 0
# The two directions in bidirectional tracing of loops
ndir = 2
for idir in range(ndir):
# Creating arrays which will store all the loops points coordinates, flux,
# angle and radius.
# xl, yl are the x and y coordinates
xl = np.zeros((npmax + 1,), dtype=np.float32)
yl = np.zeros((npmax + 1,), dtype=np.float32)
# zl is the flux at each loop point
zl = np.zeros((npmax + 1,), dtype=np.float32)
# al, rl are the angles and radius involved with every loop point
al = np.zeros((npmax + 1,), dtype=np.float32)
ir = np.zeros((npmax + 1,), dtype=np.float32)
# INITIAL DIRECTION FINDING
xl[0] = istart
yl[0] = jstart
zl[0] = zstart
# This will return the angle at the first point of the loop during every
# forward or backward pass
al[0] = _initial_direction_finding(residual, xl[0], yl[0], nlen)
# `ip` denotes a point in the traced loop
for ip in range(npmax):
# The below function call will return the coordinate, flux and angle
# of the next point.
xl, yl, zl, al = _curvature_radius(residual, rmin, xl, yl, zl, al, ir, ip, nlen, idir)
# This decides when to stop tracing the loop; when then last `ngap` pixels traced
# are below zero, the tracing will stop.
iz1 = max((ip + 1 - ngap), 0)
if np.max(zl[iz1 : ip + 2]) <= 0:
ip = max(iz1 - 1, 0)
break # goto endsegm
# ENDSEGM
# RE-ORDERING LOOP COORDINATES
# After the forward pass the loop points are flipped as the backward pass starts
# from the maximum flux point
if idir == 0:
xloop = np.flip(xl[: ip + 1])
yloop = np.flip(yl[: ip + 1])
continue
if idir != 1 or ip < 1:
break
xloop = np.concatenate([xloop, xl[1 : ip + 1]])
yloop = np.concatenate([yloop, yl[1 : ip + 1]])
# Selecting only those loop points where both the coordinates are non-zero
ind = np.logical_and(xloop != 0, yloop != 0)
nind = np.sum(ind)
looplen = 0
if nind > 1:
# skip_struct
xloop = xloop[ind]
yloop = yloop[ind]
# If number of traced loop is greater than maximum stop tracing
if iloop >= nloopmax:
break # end_trace
np1 = len(xloop)
# Calculate the length of each loop
s = np.zeros((np1), dtype=np.float32)
looplen = 0
if np1 >= 2:
for ip in range(1, np1):
s[ip] = s[ip - 1] + np.sqrt((xloop[ip] - xloop[ip - 1]) ** 2 + (yloop[ip] - yloop[ip - 1]) ** 2)
looplen = s[np1 - 1]
# SKIP STRUCT: Only those loops are returned whose length is greater than the minimum
# specified
if looplen >= lmin:
loops, iloop = _loop_add(s, xloop, yloop, iloop, loops)
# ERASE LOOP IN RESIDUAL IMAGE
residual = _erase_loop_in_image(residual, istart, jstart, wid, xloop, yloop)
# END_TRACE
return loops
# The functions below this are subroutines for the OCCULT 2.
@accept_array_or_map(arg_name="image")
def bandpass_filter(image, nsm1=1, nsm2=3):
"""
Applies a band pass filter to the image.
Parameters
----------
image : `numpy.ndarray`, `sunpy.map.GenericMap`
Image to be filtered.
nsm1 : `int`
Low pass filter boxcar smoothing constant.
Defaults to 1.
nsm2 : `int`
High pass filter boxcar smoothing constant.
The value of ``nsm2`` equal to ``nsm1 + 1`` gives the best enhancement.
Defaults to 3.
Returns
-------
`numpy.ndarray`
Bandpass filtered image. If a map is input, a map is returned with new data
and the same metadata.
"""
if nsm1 >= nsm2:
msg = "nsm1 should be less than nsm2"
raise ValueError(msg)
if nsm1 <= 2:
return image - smooth(image, nsm2, "replace")
if nsm1 >= 3:
return smooth(image, nsm1, "replace") - smooth(image, nsm2, "replace")
return None
@accept_array_or_map(arg_name="image")
def smooth(image, width, nanopt="replace"):
"""
Python implementation of the IDL's ``smooth``.
Parameters
----------
image : `numpy.ndarray`, `sunpy.map.GenericMap`
Image to be filtered.
width : `int`
Width of the boxcar window. The ``width`` should always be odd but if even value is given then
``width + 1`` is used as the width of the boxcar.
nanopt : {"propagate" , "replace"}
It decides whether to propagate NAN's or replace them.
Returns
-------
`numpy.ndarray`, `sunpy.map.GenericMap`
Smoothed image. If a map is input, a map is returned with new data
and the same metadata.
References
----------
* https://www.harrisgeospatial.com/docs/smooth.html
* Emmalg's answer on stackoverflow https://stackoverflow.com/a/35777966
"""
# Make a copy of the array for the output:
filtered = np.copy(image)
# If width is even, add one
if width % 2 == 0:
width = width + 1
# get the size of each dim of the input:
r, c = image.shape
# Assume that width, the width of the window is always square.
startrc = int((width - 1) / 2)
stopr = int(r - ((width + 1) / 2) + 1)
stopc = int(c - ((width + 1) / 2) + 1)
# For all pixels within the border defined by the box size, calculate the average in the window.
# There are two options:
# Ignore NaNs and replace the value where possible.
# Propagate the NaNs
for col in range(startrc, stopc):
# Calculate the window start and stop columns
startwc = col - int(width / 2)
stopwc = col + int(width / 2) + 1
for row in range(startrc, stopr):
# Calculate the window start and stop rows
startwr = row - int(width / 2)
stopwr = row + int(width / 2) + 1
# Extract the window
window = image[startwr:stopwr, startwc:stopwc]
if nanopt == "replace":
# If we're replacing Nans, then select only the finite elements
window = window[np.isfinite(window)]
# Calculate the mean of the window
filtered[row, col] = np.mean(window)
return filtered.astype(np.float32)
def _erase_loop_in_image(image, istart, jstart, width, xloop, yloop):
"""
Makes all the points in a loop and its vicinity as zero in the original
image to prevent them from being traced again.
Parameters
----------
image : `numpy.ndarray`
Image in which the points of a loop and surrounding it are to be made zero.
istart : `int`
The ``x`` coordinate of the starting point of the loop.
jstart : `int`
The ``y`` coordinate of the starting point of the loop.
width : `int`
The number of pixels around a loop point which are also to be removed.
xloop : `numpy.ndarray`
The ``x`` coordinates of all the loop points.
yloop : `numpy.ndarray`
The ``y`` coordinates of all the loop points.
Returns
-------
`numpy.ndarray`
Image with the loop and surrounding points zeroed out..
"""
nx, ny = image.shape
# The points surrounding the first point of the loop are zeroed out
xstart = max(istart - width, 0)
xend = min(istart + width, nx - 1)
ystart = max(jstart - width, 0)
yend = min(jstart + width, ny - 1)
image[xstart : xend + 1, ystart : yend + 1] = 0.0
# All the points surrounding the loops are zeroed out
for point in range(len(xloop)):
i0 = min(max(int(xloop[point]), 0), nx - 1)
xstart = max(int(i0 - width), 0)
xend = min(int(i0 + width), nx - 1)
j0 = min(max(int(yloop[point]), 0), ny - 1)
ystart = max(int(j0 - width), 0)
yend = min(int(j0 + width), ny - 1)
image[xstart : xend + 1, ystart : yend + 1] = 0.0
return image
def _loop_add(lengths, xloop, yloop, iloop, loops):
"""
Adds the current loop to the output structures by interpolating the
coordinates.
Parameters
----------
lengths : `numpy.ndarray`
The length of loop at every point from the starting point.
xloop : `numpy.ndarray`
The ``x`` coordinates of all the points of the loop.
yloop : `numpy.ndarray`
The ``y`` coordinates of all the points of the loop.
iloop : `int`
The current loop number.
loops : `list`
It is a list of lists which contains all the previous loops.
Returns
-------
`tuple`
It contains three elements: the first one is the updated `loopfile`, the second
one is the updated `loops` list and the third one is the current loop number.
"""
# The resolution between the points
reso = 1
# The length of the loop must be greater than 3 to interpolate
nlen = max(int(lengths[-1]), 3)
# The number of points in the final loop
num_points = int(nlen / reso + 0.5)
# All the coordinates and the flux values are interpolated
interp_points = np.arange(num_points) * reso
# The one dimensional interpolation function created for interpolating x coordinates
interfunc = interpolate.interp1d(lengths, xloop, fill_value="extrapolate")
x_interp = interfunc(interp_points)
# The one dimensional interpolation function created for interpolating y coordinates
interfunc = interpolate.interp1d(lengths, yloop, fill_value="extrapolate")
y_interp = interfunc(interp_points)
iloop += 1
current = [[x_interp[i], y_interp[i]] for i in range(len(x_interp))]
loops.append(current)
return loops, iloop
def _initial_direction_finding(image, xstart, ystart, nlen):
"""
Finds the initial angle of the loop at the starting point.
Parameters
----------
image : `numpy.ndarray`
Image in which the loops are being detected.
xstart : `int`
The ``x`` coordinates of the starting point of the loop.
ystart : `int`
The ``y`` coordinates of the starting point of the loop.
nlen : `int`
The length of the guiding segment.
Returns
-------
`float`
The angle of the starting point of the loop.
"""
# The number of steps to be taken to move from one point to another
step = 1
na = 180
# Shape of the input array
nx, ny = image.shape
# Creating the bidirectional tracing segment
trace_seg_bi = step * (np.arange(nlen, dtype=np.float32) - nlen // 2).reshape((-1, 1))
# Creating an array of all angles between 0 to 180 degree
angles = np.pi * np.arange(na, dtype=np.float32) / np.float32(na).reshape((1, -1))
# Calculating the possible x and y values when you move the tracing
# segment along a particular angle
x_pos = xstart + np.matmul(trace_seg_bi, np.float32(np.cos(angles)))
y_pos = ystart + np.matmul(trace_seg_bi, np.float32(np.sin(angles)))
# Taking the ceil as images can be indexed by pixels
ix = (x_pos + 0.5).astype(int)
iy = (y_pos + 0.5).astype(int)
# All the coordinate values should be within the input range
ix = np.clip(ix, 0, nx - 1)
iy = np.clip(iy, 0, ny - 1)
# Calculating the mean flux at possible x and y locations
flux_ = image[ix, iy]
flux = np.sum(np.maximum(flux_, 0.0), axis=0) / np.float32(nlen)
# Returning the angle along which the flux is maximum
return angles[0, np.argmax(flux)]
def _curvature_radius(image, rmin, xl, yl, zl, al, ir, ip, nlen, idir):
"""
Finds the radius of curvature at the given loop point and then uses it to
find the next point in the loop.
Parameters
----------
image : `numpy.ndarray`
Image in which the loops are being detected.
rmin : `float`
The minimum radius of curvature of any point in the loop.
xl : `numpy.ndarray`
The ``x`` coordinates of all the points of the loop.
yl : `nump.ndarray`
The ``y`` coordinates of all the points of the loop.
zl : `nump.ndarray`
The flux intensity at all the points of the loop.
al : `nump.ndarray`
The angles associated with every point of the loop.
ir : `nump.ndarray`
The radius associated with every point of the loop.
ip : `int`
The current number of the point being traced in a loop.
nlen : `int`
The length of the guiding segment.
idir : `int`
The flag which denotes whether it is a forward pass or a backward pass.
`0` denotes forward pass and `1` denotes backward pass.
Returns
-------
`float`
The angle of the starting point of the loop.
"""
# Number of radial segments to be searched
rad_segments = 30
# The number of steps to be taken to move from one point to another
step = 1
nx, ny = image.shape
# The unidirectional tracing segment
trace_seg_uni = step * np.arange(nlen, dtype=np.float32).reshape((-1, 1))
# This denotes loop tracing in forward direction
if idir == 0:
sign_dir = +1
# This denotes loop tracing in backward direction
elif idir == 1:
sign_dir = -1
# `ib1` and `ib2` decide the range of radius in which the next point is to be searched
if ip == 0:
ib1 = 0
ib2 = rad_segments - 1
if ip >= 1:
ib1 = int(max(ir[ip] - 1, 0))
ib2 = int(min(ir[ip] + 1, rad_segments - 1))
# See Eqn. 6 in the paper. Getting the values of all the valid radii
rad_i = rmin / (-1.0 + 2.0 * np.arange(ib1, ib2 + 1, dtype=np.float32) / np.float32(rad_segments - 1)).reshape(
(1, -1),
)
# See Eqn 16.
beta0 = al[ip] + np.float32(np.pi / 2)
# Finding the assumed centre of the loop
# See Eqn 17, 18.
xcen = xl[ip] + rmin * np.float32(np.cos(beta0))
ycen = yl[ip] + rmin * np.float32(np.sin(beta0))
# See Eqn 19, 20.
xcen_i = xl[ip] + (xcen - xl[ip]) * (rad_i / rmin)
ycen_i = yl[ip] + (ycen - yl[ip]) * (rad_i / rmin)
# All the possible values of angle of the curved segment from cente
# See Eqn 21.
beta_i = beta0 + sign_dir * np.float32(np.matmul(trace_seg_uni, 1 / rad_i))
# Getting the possible values of the coordinates
x_pos = xcen_i - rad_i * np.float32(np.cos(beta_i))
y_pos = ycen_i - rad_i * np.float32(np.sin(beta_i))
# Taking the ceil as images can be indexed by pixels
ix = (x_pos + 0.5).astype(int)
iy = (y_pos + 0.5).astype(int)
# All the coordinate values should be within the input range
ix = np.clip(ix, 0, nx - 1)
iy = np.clip(iy, 0, ny - 1)
# Calculating the mean flux at possible x and y locations
flux_ = image[ix, iy]
# Finding the average flux at every radii
flux = np.sum(np.maximum(flux_, 0.0), axis=0) / np.float32(nlen)
# Finding the maximum flux radii
v = np.argmax(flux)
# Getting the direction angle for the next point
# See Eqn 25.
al[ip + 1] = al[ip] + sign_dir * (step / rad_i[0, v])
ir[ip + 1] = ib1 + v
# See Eqn 26.
al_mid = (al[ip] + al[ip + 1]) / 2.0
# Coordinates of the next point in the loop
xl[ip + 1] = xl[ip] + step * np.float32(np.cos(al_mid + np.pi * idir))
yl[ip + 1] = yl[ip] + step * np.float32(np.sin(al_mid + np.pi * idir))
# Bringing the coordinates values in the valid pixel range
ix_ip = min(max(int(xl[ip + 1] + 0.5), 0), nx - 1)
iy_ip = min(max(int(yl[ip + 1] + 0.5), 0), ny - 1)
zl[ip + 1] = image[ix_ip, iy_ip]
return xl, yl, zl, al