s1-denoise 0.0.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
s1_denoise/output.py ADDED
@@ -0,0 +1,551 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Module for formatting and writing denoised noise profile vectors to XML output files.
6
+
7
+ Provides data classes for SLC and GRD noise range vectors, handles subswath
8
+ concatenation and azimuth-time interpolation for GRD products, and serialises
9
+ the results to noise XML files following the Sentinel-1 annotation format.
10
+ """
11
+
12
+ from lxml import etree
13
+ from pathlib import Path
14
+ import re
15
+ import numpy as np
16
+ import copy
17
+ from scipy.interpolate import interp1d
18
+
19
+ from perseo_core.timing import PreciseDateTime
20
+
21
+ from s1_denoise.utils import iso_to_datetime, get_range_boundaries, ensure_list
22
+ from s1_denoise.logger import denoise_logger
23
+
24
+ def get_lines_and_pixels(geolocation_info):
25
+ """
26
+ Return the lines of the geolocation grid and the output pixel axis.
27
+
28
+ Parameters
29
+ ----------
30
+ geolocation_info : dict
31
+ `geolocationGrid` section of the product annotation.
32
+
33
+ Returns
34
+ -------
35
+ unique_lines : list of int
36
+ Sorted unique line numbers of the geolocation grid points.
37
+ pixels : numpy.ndarray
38
+ Output pixels, every 40 samples from 0, with the last pixel of the grid appended.
39
+ """
40
+
41
+ grid_points = geolocation_info['geolocationGridPointList']['geolocationGridPoint']
42
+
43
+ unique_lines = sorted({
44
+ int(d['line'])
45
+ for d in grid_points
46
+ if 'line' in d and str(d['line']).isdigit()
47
+ })
48
+
49
+ max_pixel = max({
50
+ int(d['pixel'])
51
+ for d in grid_points
52
+ if 'pixel' in d and str(d['pixel']).isdigit()
53
+ })
54
+
55
+ pixels = np.arange(0, max_pixel, 40)
56
+ if pixels[-1] != max_pixel:
57
+ pixels = np.append(pixels, max_pixel)
58
+
59
+ return unique_lines, pixels
60
+
61
+ class NoiseRangeVector_SLC:
62
+ """
63
+ Noise range vector of an SLC burst, in the format of the noise annotation.
64
+
65
+ The simulated noise is divided by the sine of the incidence angle, scaled by the squared digital number
66
+ value and interpolated on the output pixels.
67
+
68
+ Parameters
69
+ ----------
70
+ azimuth_start_time : str
71
+ Azimuth start time of the burst, in ISO format.
72
+ line : int
73
+ Line number of the vector.
74
+ pixels : numpy.ndarray
75
+ Output pixel indices.
76
+ noise_values : numpy.ndarray
77
+ Simulated noise for each range sample (linear power).
78
+ incidence_angles : numpy.ndarray
79
+ Sine of the incidence angle for each range sample.
80
+ lut_value : float
81
+ Digital number calibration value from AUX_PP1.
82
+
83
+ Attributes
84
+ ----------
85
+ azimuthTime : str
86
+ Azimuth start time of the burst.
87
+ line : int
88
+ Line number of the vector.
89
+ pixels : numpy.ndarray
90
+ Output pixel indices.
91
+ pixel_count : int
92
+ Number of output pixels.
93
+ noise_values : numpy.ndarray
94
+ Output noise values on the output pixels.
95
+ noiseRangeLut_count : int
96
+ Number of output noise values.
97
+ """
98
+
99
+ def __init__(self, azimuth_start_time, line, pixels, noise_values, incidence_angles, lut_value):
100
+
101
+ self.azimuthTime = azimuth_start_time
102
+ self.line = line
103
+ self.pixels = pixels
104
+ self.pixel_count = len(self.pixels)
105
+
106
+ noise = noise_values / incidence_angles
107
+
108
+ noise *= lut_value ** 2
109
+ noise = np.interp(self.pixels, np.arange(len(noise)), noise)
110
+
111
+ self.noise_values = noise
112
+ self.noiseRangeLut_count = len(self.noise_values)
113
+
114
+ class NoiseRangeVector_GRD:
115
+ """
116
+ Merged cross-subswath noise range vector of a GRD azimuth line, in the format of the noise annotation.
117
+
118
+ The simulated noise is divided by the sine of the incidence angle, scaled by the squared digital number
119
+ value and interpolated from the sample pixel coordinates to the output pixels.
120
+
121
+ Parameters
122
+ ----------
123
+ start_az_time : str
124
+ Azimuth time of the vector, in ISO format.
125
+ noise_values : array_like
126
+ Simulated noise for each sample (linear power), concatenated over the subswaths.
127
+ incidence_angles : array_like
128
+ Sine of the incidence angle for each sample.
129
+ line_no : int
130
+ Line number of the vector.
131
+ pixels_array : list of str
132
+ Output pixel indices.
133
+ sample_pixel_coords : array_like
134
+ Pixel coordinate of each noise sample.
135
+ lut_value : float
136
+ Digital number calibration value from AUX_PP1.
137
+
138
+ Attributes
139
+ ----------
140
+ azimuthTime : str
141
+ Azimuth time of the vector.
142
+ line : int
143
+ Line number of the vector.
144
+ pixels : list of str
145
+ Output pixel indices.
146
+ pixel_count : int
147
+ Number of output pixels.
148
+ noise_values : numpy.ndarray
149
+ Output noise values on the output pixels.
150
+ noiseRangeLut_count : int
151
+ Number of output noise values.
152
+ """
153
+
154
+ def __init__(self, start_az_time, noise_values, incidence_angles, line_no, pixels_array, sample_pixel_coords, lut_value):
155
+
156
+ self.azimuthTime = start_az_time
157
+ self.line = line_no
158
+ self.pixels = pixels_array
159
+ self.pixel_count = len(self.pixels)
160
+
161
+ noise = np.array(noise_values) / np.array(incidence_angles)
162
+ noise *= lut_value ** 2
163
+ noise = np.interp(np.array(self.pixels, dtype=float), sample_pixel_coords, noise)
164
+
165
+ self.noise_values = noise
166
+ self.noiseRangeLut_count = len(self.noise_values)
167
+
168
+ class OutputNoiseVectors_SLC:
169
+ """
170
+ Output noise range vectors of an SLC channel (polarization and subswath).
171
+
172
+ Parameters
173
+ ----------
174
+ adsHeader : dict
175
+ `adsHeader` section of the product annotation.
176
+ noiseRangeVectors : dict
177
+ Simulated noise per burst, mapping ``(burst_index, start_azimuth_time)`` to
178
+ ``(noise, geolocation_grid, sin_incidence, dn_value)``, where `noise` is the slant range simulated noise,
179
+ `geolocation_grid` the `geolocationGrid` annotation section, `sin_incidence` the sine of the incidence angles
180
+ and `dn_value` the digital number calibration value.
181
+
182
+ Attributes
183
+ ----------
184
+ ads_header : dict
185
+ `adsHeader` section of the product annotation.
186
+ noise_range_vectors : list of NoiseRangeVector_SLC
187
+ One noise range vector per burst, at the geolocation grid line of the burst.
188
+ vector_count : int
189
+ Number of noise range vectors.
190
+ """
191
+
192
+ def __init__(self, adsHeader: dict, noiseRangeVectors):
193
+
194
+ self.ads_header = adsHeader
195
+ self.noise_range_vectors = [] # list
196
+
197
+ geo_grid_lines = None
198
+ pixels = None
199
+
200
+ for burst_values, noise_values in noiseRangeVectors.items():
201
+ rel_burst = burst_values[0]
202
+ start_az_time = burst_values[1]
203
+ sim_noise = noise_values[0]
204
+ geo_grid_info = noise_values[1]
205
+ incidence_angles = noise_values[2]
206
+ dn_value = noise_values[3]
207
+
208
+ # the geolocation grid is the same for all the bursts of the swath
209
+ if geo_grid_lines is None:
210
+ geo_grid_lines, pixels = get_lines_and_pixels(geo_grid_info)
211
+
212
+ noise_range_vec = NoiseRangeVector_SLC(start_az_time,
213
+ geo_grid_lines[rel_burst],
214
+ pixels,
215
+ sim_noise,
216
+ incidence_angles,
217
+ dn_value)
218
+
219
+ self.noise_range_vectors.append(noise_range_vec)
220
+
221
+ self.vector_count = len(noiseRangeVectors)
222
+
223
+ class OutputNoiseVectors_GRD:
224
+ """
225
+ Output cross-subswath noise range vectors of a GRD channel (polarization).
226
+
227
+ The noise vectors of each subswath are extended in azimuth to the full scene extent (see `extend_noise_grd`),
228
+ then, for each azimuth time step, concatenated along range according to the swath merging boundaries,
229
+ removing the samples of the previous subswath that fall in the overlap region.
230
+
231
+ Parameters
232
+ ----------
233
+ adsHeader : dict
234
+ `adsHeader` section of the product annotation.
235
+ swath_merges : list of dict
236
+ `swathMerge` entries of the product annotation.
237
+ noiseRangeVectors : dict
238
+ Simulated noise per block, mapping ``(block_index, subswath)`` to
239
+ ``(azimuth_time, noise, sin_incidence)``, with the noise in ground range.
240
+ dn_value : float
241
+ Digital number calibration value from AUX_PP1.
242
+ tot_vec_out : int
243
+ Number of annotated antenna pattern records. Not used.
244
+
245
+ Attributes
246
+ ----------
247
+ ads_header : dict
248
+ `adsHeader` section of the product annotation.
249
+ noise_range_vectors : list of NoiseRangeVector_GRD
250
+ One merged noise range vector per azimuth time step.
251
+ vector_count : int
252
+ Number of noise range vectors.
253
+ """
254
+
255
+ def __init__(self, adsHeader: dict, swath_merges, noiseRangeVectors, dn_value, tot_vec_out):
256
+
257
+ self.ads_header = adsHeader
258
+ self.noise_range_vectors = []
259
+
260
+ # Collect unique subswath identifiers preserving their order
261
+ subswaths = list(dict.fromkeys(key[1] for key in noiseRangeVectors))
262
+
263
+ # Determine the total number of azimuth lines across all swath boundaries
264
+ max_lines = max(
265
+ int(d['lastAzimuthLine'])
266
+ for p in swath_merges
267
+ for d in ensure_list(p['swathBoundsList']['swathBounds'])
268
+ )
269
+
270
+ # Extend each subswath's noise vectors to cover the full azimuth extent
271
+ # Burst vectors are re-indexed sequentially and extrapolated to fill any gaps beyond the first/last burst up to the scene boundaries
272
+ subswath_extended = {}
273
+ for subswath in subswaths:
274
+ burst_indices = sorted(k[0] for k in noiseRangeVectors if k[1] == subswath)
275
+ sw_vectors = {
276
+ j: noiseRangeVectors[(burst_no, subswath)]
277
+ for j, burst_no in enumerate(burst_indices)
278
+ }
279
+ subswath_extended[subswath] = extend_noise_grd(
280
+ sw_vectors, self.ads_header['startTime'], self.ads_header['stopTime'], max_lines
281
+ )
282
+
283
+ # Step 2: for each azimuth time step, concatenate subswaths in range.
284
+ # All subswaths share the same number of time steps after the extension above.
285
+ tot_vec = len(next(iter(subswath_extended.values())))
286
+
287
+ for i in range(tot_vec):
288
+ # Azimuth time and line number are taken from the first subswath (same for all)
289
+ az_time = subswath_extended[subswaths[0]][i][0]
290
+ line_no = subswath_extended[subswaths[0]][i][3]
291
+
292
+ noise = []
293
+ incidence_angles = []
294
+ sample_pixel_coords = []
295
+ pixels = []
296
+ last_prec = None
297
+ first_prec = None
298
+
299
+ for subswath in subswaths:
300
+ noise_sw = subswath_extended[subswath][i][1]
301
+ angles_sw = subswath_extended[subswath][i][2]
302
+
303
+ # Retrieve the pixel range covered by this subswath at the current azimuth time
304
+ first, last = get_range_boundaries(swath_merges=swath_merges, subswath=subswath, az_time=az_time)
305
+
306
+ # Remove samples from the previous subswath that fall in the overlap region
307
+ if last_prec is not None:
308
+ overlap_pixels = max(0, last_prec - first + 1)
309
+ if overlap_pixels > 0:
310
+ prev_pixel_width = last_prec - first_prec + 1
311
+ overlap_samples = round(overlap_pixels * len(noise) / prev_pixel_width)
312
+ noise = noise[:-overlap_samples]
313
+ incidence_angles = incidence_angles[:-overlap_samples]
314
+ sample_pixel_coords = sample_pixel_coords[:-overlap_samples]
315
+
316
+ # Append this subswath's samples, mapping them uniformly across its pixel range
317
+ pixels.extend([first, last])
318
+ n_samples = len(noise_sw)
319
+ sample_pixel_coords.extend(np.linspace(first, last, n_samples).tolist())
320
+ noise.extend(list(noise_sw))
321
+ incidence_angles.extend(list(angles_sw))
322
+
323
+ first_prec = first
324
+ last_prec = last
325
+
326
+ # Build the merged noise range vector for this azimuth line and store it
327
+ pixel_line = calculate_pixel_line(pixels)
328
+ noise_range_vec = NoiseRangeVector_GRD(az_time, noise, incidence_angles, line_no, pixel_line, sample_pixel_coords, dn_value)
329
+ self.noise_range_vectors.append(noise_range_vec)
330
+
331
+ self.vector_count = len(self.noise_range_vectors)
332
+
333
+
334
+ def calculate_pixel_line(pixel_bound_list):
335
+ """
336
+ Build the output pixel axis of a merged GRD noise range vector.
337
+
338
+ Within each subswath, pixels are taken every 40 samples, always including the last pixel of the subswath; the
339
+ next subswath starts after the last pixel of the previous one.
340
+
341
+ Parameters
342
+ ----------
343
+ pixel_bound_list : list of int
344
+ First and last pixel of each subswath, as ``[first_1, last_1, first_2, last_2, ...]``. Only the last pixels
345
+ are used.
346
+
347
+ Returns
348
+ -------
349
+ list of str
350
+ Output pixel indices.
351
+ """
352
+
353
+ pixels = []
354
+ start = 0
355
+ for bound in pixel_bound_list[1::2]:
356
+ pixels.extend(np.arange(start, bound, 40))
357
+ if bound not in pixels:
358
+ pixels.append(bound)
359
+ start = bound + 1
360
+
361
+ pixel_string = [str(int(x)) for x in pixels]
362
+
363
+ return pixel_string
364
+
365
+ def extend_noise_grd(input_vectors, start_time, stop_time, max_lines):
366
+ """
367
+ Extend the noise vectors of a GRD subswath to the full azimuth extent of the scene.
368
+
369
+ The vectors are first resampled to a common length (the longest one), then linearly interpolated in azimuth on
370
+ a 1 s grid from `start_time` to `stop_time` (included). Values outside the input time span are held constant
371
+ at the first/last vector.
372
+
373
+ Parameters
374
+ ----------
375
+ input_vectors : dict
376
+ Noise vectors of the subswath, mapping consecutive indices from 0 to
377
+ ``(azimuth_time, noise, sin_incidence)``, with the azimuth time in ISO format.
378
+ start_time : str
379
+ Scene start time, in ISO format.
380
+ stop_time : str
381
+ Scene stop time, in ISO format.
382
+ max_lines : int
383
+ Total number of azimuth lines of the scene, used to compute the line number of each output vector.
384
+
385
+ Returns
386
+ -------
387
+ dict
388
+ Extended noise vectors, mapping the output index to ``(azimuth_time, noise, sin_incidence, line)``, with
389
+ the azimuth time as ISO string and `noise`, `sin_incidence` as numpy.ndarray.
390
+ """
391
+
392
+ output_vectors = dict()
393
+
394
+ # create new azimuth time axis
395
+ az_times_old = [iso_to_datetime(v[0]).sec85 for v in input_vectors.values()]
396
+ start_sec85 = iso_to_datetime(start_time).sec85
397
+ stop_sec85 = iso_to_datetime(stop_time).sec85
398
+ az_times_new = np.arange(start_sec85, stop_sec85, 1.0)
399
+
400
+ # add the stop time as last element, unless it is already there (compare in seconds, with tolerance)
401
+ if az_times_new.size == 0 or not np.isclose(az_times_new[-1], stop_sec85, rtol=0.0, atol=1e-6):
402
+ az_times_new = np.append(az_times_new, stop_sec85)
403
+
404
+ tot_vec_out = len(az_times_new)
405
+
406
+ target_len = max(len(input_vectors[j][1]) for j in range(len(input_vectors)))
407
+
408
+ # resample noise and incidence angles of each vector to the same length,
409
+ # without modifying the input vectors
410
+ x_new = np.linspace(0, 1, target_len)
411
+ resampled_vectors = []
412
+ for j in range(len(input_vectors)):
413
+ vec = list(input_vectors[j]) # make a mutable copy
414
+ for idx in (1, 2): # noise, incidence_angles
415
+ y = np.array(vec[idx])
416
+ x = np.linspace(0, 1, len(y))
417
+ vec[idx] = np.interp(x_new, x, y).tolist()
418
+ resampled_vectors.append(vec)
419
+
420
+ noise_old = np.stack([v[1] for v in resampled_vectors])
421
+ angles_old = np.stack([v[2] for v in resampled_vectors])
422
+
423
+ interp_noise = interp1d(
424
+ az_times_old,
425
+ noise_old,
426
+ axis=0,
427
+ bounds_error=False,
428
+ fill_value=(noise_old[0], noise_old[-1])
429
+ )
430
+
431
+ interp_angles = interp1d(
432
+ az_times_old,
433
+ angles_old,
434
+ axis=0,
435
+ bounds_error=False,
436
+ fill_value=(angles_old[0], angles_old[-1])
437
+ )
438
+
439
+ noise_new = interp_noise(az_times_new)
440
+ incidence_angles_new = interp_angles(az_times_new)
441
+
442
+ delta_line = (az_times_new[-1] - az_times_new[0])/max_lines
443
+
444
+
445
+ for i in range(tot_vec_out):
446
+
447
+ line_number = int(np.round((az_times_new[i]-az_times_new[0])/delta_line))
448
+
449
+ output_vectors.update({
450
+ i: (PreciseDateTime.from_sec85(az_times_new[i]).isoformat(timespec="microseconds").rstrip("Z"), noise_new[i, :], incidence_angles_new[i, :], line_number)
451
+ })
452
+
453
+ return output_vectors
454
+
455
+ def build_filename(header):
456
+ """
457
+ Build the output noise file name from the product header.
458
+
459
+ The name follows the Sentinel-1 annotation naming convention, with the ``denoise`` prefix:
460
+ ``denoise-<mission>-<swath>-<type>-<pol>-<start>-<stop>-<orbit>-<datatake>-<image>.xml``.
461
+
462
+ Parameters
463
+ ----------
464
+ header : dict
465
+ `adsHeader` section of the product annotation.
466
+
467
+ Returns
468
+ -------
469
+ str
470
+ Output file name.
471
+ """
472
+
473
+ def clean_time(s):
474
+ """Remove the fractional seconds and all ``.``, ``-`` and ``:`` characters from a timestamp."""
475
+ return re.sub(r"[.\-:]", "", s[:-7])
476
+
477
+ filename = (
478
+ f"denoise"
479
+ f"-{header['missionId']}-{header['swath']}-{header['productType']}-{header['polarisation']}-"
480
+ f"{clean_time(header['startTime'])}-{clean_time(header['stopTime'])}-" # remove all ":", "." and "-" from timestamps
481
+ f"{header['absoluteOrbitNumber'].zfill(6)}-" # add 0 padding
482
+ f"{int(header['missionDataTakeId']):06x}-" # convert mission datatake ID to hexadecimal
483
+ f"{header['imageNumber']}.xml"
484
+ )
485
+
486
+ return filename
487
+
488
+ def write_to_xml(noise, output_path: Path, safe_path):
489
+ """
490
+ Write the output noise vectors to an XML noise file.
491
+
492
+ The file is written in a subfolder of `output_path` named after the input product, with the `adsHeader` and the
493
+ `noiseRangeVectorList`. The `noiseAzimuthVectorList` is copied unchanged from the nominal noise file of the
494
+ input product, if present.
495
+
496
+ Parameters
497
+ ----------
498
+ noise : OutputNoiseVectors_SLC or OutputNoiseVectors_GRD
499
+ Output noise vectors of one channel.
500
+ output_path : pathlib.Path
501
+ Output directory.
502
+ safe_path : pathlib.Path
503
+ Path to the input SAFE product.
504
+
505
+ Returns
506
+ -------
507
+ pathlib.Path
508
+ Path of the written XML file.
509
+ """
510
+
511
+ root = etree.Element("noise")
512
+
513
+ # adsHeader
514
+ header = etree.SubElement(root, "adsHeader")
515
+ for key, value in noise.ads_header.items():
516
+ child = etree.SubElement(header, key)
517
+ child.text = str(value)
518
+
519
+ # noiseRangeVectorList
520
+ vector_list = etree.SubElement(root, "noiseRangeVectorList", {"count": str(noise.vector_count)})
521
+ for v in noise.noise_range_vectors:
522
+ vec_elem = etree.SubElement(vector_list, "noiseRangeVector")
523
+
524
+ etree.SubElement(vec_elem, "azimuthTime").text = str(v.azimuthTime)
525
+ etree.SubElement(vec_elem, "line").text = str(v.line)
526
+
527
+ etree.SubElement(vec_elem, "pixel", {"count": str(v.pixel_count)}).text = ' '.join(map(str, v.pixels))
528
+ etree.SubElement(vec_elem, "noiseRangeLut", {"count": str(v.noiseRangeLut_count)}).text = " ".join(f"{x:.6e}" for x in v.noise_values)
529
+
530
+ xml_tree = etree.ElementTree(root)
531
+
532
+ output_path = output_path / Path(safe_path.stem)
533
+ output_path.mkdir(parents=True, exist_ok=True) # check output folder exists
534
+ output_filename = build_filename(noise.ads_header)
535
+ output_file_path = Path(str(output_path.resolve() / output_filename.lower()))
536
+
537
+ # noiseAzimuthVectorList
538
+ nominal_noise_file = safe_path / "annotation/calibration" / (output_file_path.stem[2:] + ".xml")
539
+ if nominal_noise_file.exists():
540
+ nominal_noise_tree = etree.parse(nominal_noise_file)
541
+ nominal_noise_root = nominal_noise_tree.getroot()
542
+ noise_az_list = nominal_noise_root.find("noiseAzimuthVectorList")
543
+
544
+ if noise_az_list is not None:
545
+ root.append(copy.deepcopy(noise_az_list))
546
+
547
+ etree.indent(root, space=" ")
548
+ xml_tree.write(output_file_path, pretty_print=True, encoding="utf-8", xml_declaration=True)
549
+ denoise_logger.info(f"Written output to {output_file_path.resolve()}")
550
+
551
+ return output_file_path