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.
@@ -0,0 +1,203 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Decode Sentinel-1 plain BAQ NOISE-packet samples (ICD "Data Format C", 3/4/5-bit), which ``sentinel1decoder`` cannot
6
+ decode.
7
+
8
+ The user data field is made of four 16-bit-aligned sections (IE, IO, QE, QO) of `num_quads` sign-magnitude
9
+ `code_bits`-wide codes (MSB first); QE alone has an extra 8-bit Threshold Index (THIDX) field every 128 samples.
10
+ The raw quantizer code is returned, not the NRL/sigma-corrected amplitude: this is valid only while THIDX stays below
11
+ the ICD reconstruction threshold, which is checked explicitly (see `QE_THIDX_THRESHOLD`).
12
+ """
13
+
14
+ import sys
15
+ import numpy as np
16
+
17
+ from s1_denoise.logger import denoise_logger
18
+
19
+ QE_BLOCK_LEN = 128
20
+ QE_HEADER_BITS = 8
21
+
22
+ # THIDX threshold per BAQ mode below which the ICD-decoded sample value equals the raw
23
+ # quantizer code (ICD section 9.1.2.2); at or above it, NRL/sigma-factor reconstruction
24
+ # would be needed instead. Matches AUX_INS's thresholdLutList (thidxThreshold).
25
+ QE_THIDX_THRESHOLD = {3: 3, 4: 5, 5: 10}
26
+
27
+
28
+ def _section_align(bit_pos: int) -> int:
29
+ """
30
+ Round a bit position up to the next 16-bit boundary, where IE/IO/QE/QO sections start.
31
+
32
+ Parameters
33
+ ----------
34
+ bit_pos : int
35
+ Bit position.
36
+
37
+ Returns
38
+ -------
39
+ int
40
+ Aligned bit position.
41
+ """
42
+
43
+ rem = bit_pos % 16
44
+
45
+ return bit_pos if rem == 0 else bit_pos + (16 - rem)
46
+
47
+
48
+ def _decode_plain_section(bits: np.ndarray, offset: int, nbits: int, count: int):
49
+ """
50
+ Decode a contiguous run of `count` sign-magnitude `nbits`-wide codes, without headers.
51
+
52
+ Parameters
53
+ ----------
54
+ bits : numpy.ndarray
55
+ Unpacked user data bits (0/1).
56
+ offset : int
57
+ Bit position of the first code.
58
+ nbits : int
59
+ Code width in bits, sign bit included.
60
+ count : int
61
+ Number of codes to decode.
62
+
63
+ Returns
64
+ -------
65
+ values : numpy.ndarray
66
+ Signed integer quantizer codes.
67
+ offset : int
68
+ Bit position following the last decoded code.
69
+ """
70
+
71
+ total = nbits * count
72
+ chunk = bits[offset : offset + total].reshape(count, nbits)
73
+ weights = 1 << np.arange(nbits - 1, -1, -1)
74
+ codes = chunk @ weights
75
+ sign = (codes >> (nbits - 1)) & 1
76
+ mag = codes & ((1 << (nbits - 1)) - 1)
77
+ values = np.where(sign == 1, -mag, mag)
78
+
79
+ return values, offset + total
80
+
81
+
82
+ def _decode_qe_section(bits: np.ndarray, offset: int, nbits: int, count: int):
83
+ """
84
+ Decode the QE section, skipping the 8-bit THIDX field before every 128-sample block.
85
+
86
+ Each THIDX is checked to stay below the ICD reconstruction threshold, where the decoded value is the raw quantizer
87
+ code (see `QE_THIDX_THRESHOLD`).
88
+
89
+ Parameters
90
+ ----------
91
+ bits : numpy.ndarray
92
+ Unpacked user data bits (0/1).
93
+ offset : int
94
+ Bit position of the first THIDX field.
95
+ nbits : int
96
+ Code width in bits, sign bit included.
97
+ count : int
98
+ Number of codes to decode.
99
+
100
+ Returns
101
+ -------
102
+ values : numpy.ndarray
103
+ Signed integer quantizer codes.
104
+ offset : int
105
+ Bit position following the last decoded code.
106
+
107
+ Raises
108
+ ------
109
+ SystemExit
110
+ If a THIDX is at or above the reconstruction threshold (exit code 128).
111
+ """
112
+
113
+ threshold = QE_THIDX_THRESHOLD[nbits]
114
+ values = np.zeros(count, dtype=int)
115
+ pos = offset
116
+ idx = 0
117
+
118
+ while idx < count:
119
+ n = min(QE_BLOCK_LEN, count - idx)
120
+
121
+ thidx = 0
122
+ for bit in bits[pos : pos + QE_HEADER_BITS]:
123
+ thidx = (thidx << 1) | int(bit)
124
+ if thidx >= threshold:
125
+ denoise_logger.error(
126
+ f"QE block THIDX={thidx} is at or above the ICD reconstruction threshold ({threshold}) for BAQ {nbits}-bit mode; the raw quantizer code no longer equals the decoded sample value (NRL/sigma-factor reconstruction would be needed instead). Aborting rather than returning silently wrong noise power values."
127
+ )
128
+ sys.exit(128)
129
+
130
+ pos += QE_HEADER_BITS
131
+ block_values, pos = _decode_plain_section(bits, pos, nbits, n)
132
+ values[idx : idx + n] = block_values
133
+ idx += n
134
+
135
+ return values, pos
136
+
137
+
138
+ def decode_baq_packet(raw_user_data: bytes, num_quads: int, code_bits: int = 5):
139
+ """
140
+ Decode the user data field of one plain BAQ packet.
141
+
142
+ Parameters
143
+ ----------
144
+ raw_user_data : bytes
145
+ Packet user data field.
146
+ num_quads : int
147
+ Number of quads, i.e. of codes in each section.
148
+ code_bits : int, optional
149
+ BAQ code width in bits. Only 5 (BAQ 5-bit) is supported. Default is 5.
150
+
151
+ Returns
152
+ -------
153
+ IE, IO, QE, QO : numpy.ndarray
154
+ Quantizer codes of the even/odd I and even/odd Q samples, one array per section.
155
+
156
+ Raises
157
+ ------
158
+ SystemExit
159
+ If `code_bits` is not 5, or a QE THIDX is at or above the reconstruction threshold (exit code 128).
160
+ """
161
+
162
+ if code_bits != 5:
163
+ denoise_logger.error(
164
+ f"BAQ {code_bits}-bit decoding is not validated against real data yet (only 5-bit noise packets have been verified); refusing to decode silently to avoid producing unverified noise power values."
165
+ )
166
+ sys.exit(128)
167
+
168
+ bits = np.unpackbits(np.frombuffer(raw_user_data, dtype=np.uint8)).astype(np.int64)
169
+
170
+ IE, pos = _decode_plain_section(bits, 0, code_bits, num_quads)
171
+ pos = _section_align(pos)
172
+ IO, pos = _decode_plain_section(bits, pos, code_bits, num_quads)
173
+ pos = _section_align(pos)
174
+ QE, pos = _decode_qe_section(bits, pos, code_bits, num_quads)
175
+ pos = _section_align(pos)
176
+ QO, _ = _decode_plain_section(bits, pos, code_bits, num_quads)
177
+
178
+ return IE, IO, QE, QO
179
+
180
+
181
+ def quads_to_complex(IE: np.ndarray, IO: np.ndarray, QE: np.ndarray, QO: np.ndarray) -> np.ndarray:
182
+ """
183
+ Interleave the decoded sections into complex samples in the standard order.
184
+
185
+ The output is ``IE[0] + 1j*QE[0], IO[0] + 1j*QO[0], IE[1] + 1j*QE[1], ...``.
186
+
187
+ Parameters
188
+ ----------
189
+ IE, IO, QE, QO : numpy.ndarray
190
+ Quantizer codes of the even/odd I and even/odd Q samples, as returned by `decode_baq_packet`.
191
+
192
+ Returns
193
+ -------
194
+ numpy.ndarray
195
+ Complex64 samples, of length ``2 * len(IE)``.
196
+ """
197
+
198
+ num_quads = len(IE)
199
+ out = np.empty(num_quads * 2, dtype=np.complex64)
200
+ out[0::2] = IE + 1j * QE
201
+ out[1::2] = IO + 1j * QO
202
+
203
+ return out
s1_denoise/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ CLI module for the denoising processor, built with Click.
6
+
7
+ Defines the ``denoise_cli`` command, which reads a Job Order file to locate input L1 SAFE products and auxiliary
8
+ files, generates noise profiles, writes output XML files, and, when the nominal noise annotations are available
9
+ in the input product, computes the mean and maximum absolute error against them.
10
+ """
11
+
12
+ import click
13
+ from pathlib import Path
14
+ import sys
15
+
16
+ from perseo_core.timing import PreciseDateTime
17
+
18
+ from s1_denoise.logger import denoise_logger
19
+ from s1_denoise.noise_profiles import generate_noise_profiles, print_exec_time
20
+ from s1_denoise import __version__
21
+ from s1_denoise.jo_parser import JobOrder, find_l0n
22
+ from s1_denoise.auxiliary_files import AuxiliaryFiles
23
+ from s1_denoise.output import write_to_xml
24
+ from s1_denoise.error_calculation import calculate_error
25
+
26
+
27
+ @click.command()
28
+ @click.version_option(__version__, prog_name="denoise")
29
+ @click.argument("job_order_path",
30
+ type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path)
31
+ )
32
+ def denoise_cli(job_order_path):
33
+ """
34
+ Generate noise profiles starting from L1 SAFE files.
35
+ \f
36
+ For each L1 product listed in the Job Order, the associated L0N product is looked up, noise vectors are
37
+ reconstructed and written as XML files in the output directory. If the product contains the corresponding
38
+ nominal noise annotation, the mean and maximum absolute errors (dB) are logged.
39
+
40
+ Parameters
41
+ ----------
42
+ job_order_path : pathlib.Path
43
+ Path to the Job Order XML file.
44
+
45
+ Raises
46
+ ------
47
+ SystemExit
48
+ With code 128 on any unexpected error; ``SystemExit`` raised during processing (e.g. by Job Order
49
+ validation) is propagated. The exit code is always logged before leaving.
50
+ """
51
+
52
+ return_code = 0
53
+ try:
54
+ jo = JobOrder(job_order_path)
55
+
56
+ # set log level
57
+ denoise_logger.setLevel(jo.log_level)
58
+ denoise_logger.debug("Starting noise processor...")
59
+
60
+ # read auxiliary files
61
+ aux_files = AuxiliaryFiles(mission_id = jo.mission_id,
62
+ pp1_path = jo.aux_pp1_filepath,
63
+ cal_path = jo.aux_cal_filepath,
64
+ ins_path = jo.aux_ins_filepath)
65
+
66
+ # read file to process from Job Order
67
+ product_path_list = [
68
+ Path(file_name)
69
+ for file_names in jo.input_files_to_process
70
+ for file_name in file_names['List_of_File_Names']['File_Name']
71
+ ]
72
+
73
+ out_filepath_list = []
74
+
75
+ start_time = PreciseDateTime.now()
76
+
77
+ for current_prod in product_path_list:
78
+
79
+ # respective L0N product
80
+ associated_l0n = find_l0n(l1a=current_prod, l0n_list=jo.input_l0n_files)
81
+
82
+ # denoise product
83
+ noise_vectors_list = generate_noise_profiles(prod=current_prod, l0n_file=associated_l0n, aux_files=aux_files)
84
+
85
+ # generate output files
86
+ for elem in noise_vectors_list:
87
+
88
+ out_filepath = write_to_xml(noise=elem, output_path=jo.output_path, safe_path=current_prod)
89
+ out_filepath_list.append(out_filepath)
90
+
91
+ # if nominal noise files exist, calculate MAE
92
+ nominal_noise = current_prod / "annotation/calibration" / (out_filepath.stem[2:] + ".xml")
93
+ if nominal_noise.exists():
94
+
95
+ mae_on_denoise, max_err_on_denoise = calculate_error(nominal_noise_filepath=nominal_noise,
96
+ denoise_filepath=out_filepath)
97
+
98
+ if abs(mae_on_denoise) < 1.0:
99
+ denoise_logger.info(f"Mean absolute error on denoising: {mae_on_denoise:.2e} dB")
100
+ else:
101
+ denoise_logger.warning(f"High mean absolute error on denoising: {mae_on_denoise:.2e} dB")
102
+
103
+ denoise_logger.info(f"Maximum absolute error on denoising: {max_err_on_denoise:.2e} dB")
104
+
105
+ denoise_logger.info("Denoising process terminated successfully")
106
+
107
+ print_exec_time(elapsed_seconds=PreciseDateTime.now() - start_time)
108
+
109
+ except SystemExit as e:
110
+ return_code = e.code
111
+ raise
112
+ except Exception:
113
+ # any unhandled error stops the processor with exit code 128
114
+ denoise_logger.exception("Unexpected error. Aborting...")
115
+ return_code = 128
116
+ sys.exit(128)
117
+ finally:
118
+ denoise_logger.info(f"Exit code: {return_code}")
@@ -0,0 +1,262 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Compute the error between nominal and reconstructed Sentinel-1 noise vectors.
6
+
7
+ Provides utilities to read noise XML files, interpolate the noise range vectors onto a common range/azimuth grid, and
8
+ compute the mean and maximum absolute differences in dB between the two, after normalisation by the digital number
9
+ scaling factor of the product.
10
+ """
11
+
12
+ import numpy as np
13
+ import xml.etree.ElementTree as ET
14
+ import numpy as np
15
+ from scipy.interpolate import interp1d, RegularGridInterpolator
16
+
17
+ from perseo_core.timing import PreciseDateTime
18
+
19
+ from s1_denoise.utils import linear_to_db
20
+
21
+ DN_VALUES = {
22
+ ("IW", "SLC"): 237,
23
+ ("IW", "GRD"): 474,
24
+ ("EW", "SLC"): 543,
25
+ ("EW", "GRD"): 1087
26
+ }
27
+
28
+ def read_noise_xml(path):
29
+ """
30
+ Read the noise range vectors from a Sentinel-1 noise XML file (GRD or SLC).
31
+
32
+ An XML namespace, if present, is handled.
33
+
34
+ Parameters
35
+ ----------
36
+ path : str or pathlib.Path
37
+ Path of the noise XML file.
38
+
39
+ Returns
40
+ -------
41
+ times : numpy.ndarray
42
+ Azimuth times of the noise range vectors, as PreciseDateTime objects.
43
+ pixels_list : list of numpy.ndarray
44
+ Range pixel indices of each vector.
45
+ noise_list : list of numpy.ndarray
46
+ Noise range LUT values of each vector (linear scale).
47
+ """
48
+
49
+ tree = ET.parse(path)
50
+ root = tree.getroot()
51
+
52
+ # Detect namespace (if present)
53
+ if "}" in root.tag:
54
+ ns = {"ns": root.tag.split("}")[0].strip("{")}
55
+ search_path = ".//ns:noiseRangeVector"
56
+ else:
57
+ ns = {}
58
+ search_path = ".//noiseRangeVector"
59
+
60
+ times = []
61
+ pixels_list = []
62
+ noise_list = []
63
+
64
+ for vec in root.findall(search_path, ns):
65
+
66
+ def find(tag):
67
+ """Find a child tag of the current noise range vector, honouring the XML namespace."""
68
+ if ns:
69
+ return vec.find(f"ns:{tag}", ns)
70
+ return vec.find(tag)
71
+
72
+ az_time = PreciseDateTime.fromisoformat(find("azimuthTime").text)
73
+ pixels = np.array(list(map(float, find("pixel").text.split())))
74
+ noise = np.array(list(map(float, find("noiseRangeLut").text.split())))
75
+
76
+ times.append(az_time)
77
+ pixels_list.append(pixels)
78
+ noise_list.append(noise)
79
+
80
+ return np.array(times), pixels_list, noise_list
81
+
82
+
83
+ def interpolate_noise(pixels_list, noise_list, common_pixels):
84
+ """
85
+ Linearly interpolate each noise range vector onto a common range pixel axis.
86
+
87
+ Parameters
88
+ ----------
89
+ pixels_list : list of numpy.ndarray
90
+ Range pixel indices of each vector.
91
+ noise_list : list of numpy.ndarray
92
+ Noise values of each vector.
93
+ common_pixels : numpy.ndarray
94
+ Common range pixel axis.
95
+
96
+ Returns
97
+ -------
98
+ numpy.ndarray
99
+ Interpolated noise, shape ``(n_vectors, len(common_pixels))``; NaN outside each vector's pixel span.
100
+ """
101
+
102
+ interp_noises = []
103
+ for pixels, noise in zip(pixels_list, noise_list):
104
+ f = interp1d(
105
+ pixels,
106
+ noise,
107
+ kind="linear",
108
+ bounds_error=False,
109
+ fill_value=np.nan,
110
+ )
111
+ interp_noises.append(f(common_pixels))
112
+ return np.array(interp_noises)
113
+
114
+
115
+ def build_common_grid(times1, pixels_list1, times2, pixels_list2):
116
+ """
117
+ Compute a common range pixel and azimuth time grid for two sets of noise range vectors.
118
+
119
+ Both axes have 1500 points spanning the overlap of the two sets. Azimuth times are expressed in seconds relative to
120
+ the first vector of each set.
121
+
122
+ Parameters
123
+ ----------
124
+ times1, times2 : numpy.ndarray
125
+ Azimuth times of the two sets, as PreciseDateTime objects.
126
+ pixels_list1, pixels_list2 : list of numpy.ndarray
127
+ Range pixel indices of each vector of the two sets.
128
+
129
+ Returns
130
+ -------
131
+ common_pixels : numpy.ndarray
132
+ Common range pixel axis.
133
+ times1_sec : numpy.ndarray
134
+ Azimuth times of the first set [s], relative to its first vector.
135
+ times2_sec : numpy.ndarray
136
+ Azimuth times of the second set [s], relative to its first vector.
137
+ common_times_sec : numpy.ndarray
138
+ Common azimuth time axis [s].
139
+ """
140
+
141
+ # Common pixel axis
142
+ min_pixel = max(min(p[0] for p in pixels_list1),
143
+ min(p[0] for p in pixels_list2))
144
+ max_pixel = min(max(p[-1] for p in pixels_list1),
145
+ max(p[-1] for p in pixels_list2))
146
+
147
+ common_pixels = np.linspace(min_pixel, max_pixel, 1500)
148
+
149
+ # Convert azimuth time to seconds
150
+ times1_sec = np.array([(t - times1[0]) for t in times1])
151
+ times2_sec = np.array([(t - times2[0]) for t in times2])
152
+
153
+ common_times_sec = np.linspace(
154
+ max(times1_sec[0], times2_sec[0]),
155
+ min(times1_sec[-1], times2_sec[-1]),
156
+ 1500,
157
+ )
158
+
159
+ return common_pixels, times1_sec, times2_sec, common_times_sec
160
+
161
+
162
+ def compare_noise(noise_file, denoise_file, dn):
163
+ """
164
+ Compare nominal and reconstructed noise vectors on a common grid.
165
+
166
+ Both files are interpolated onto the grid of `build_common_grid` and normalised by ``dn**2``; zero values are set to
167
+ NaN and values are clipped at 1e-12 before conversion to dB.
168
+
169
+ Parameters
170
+ ----------
171
+ noise_file : str or pathlib.Path
172
+ Nominal noise XML file.
173
+ denoise_file : str or pathlib.Path
174
+ Reconstructed noise XML file.
175
+ dn : float
176
+ Digital number scaling factor of the product (see `DN_VALUES`).
177
+
178
+ Returns
179
+ -------
180
+ common_pixels : numpy.ndarray
181
+ Common range pixel axis.
182
+ common_times_sec : numpy.ndarray
183
+ Common azimuth time axis [s].
184
+ diff_db : numpy.ndarray
185
+ Reconstructed minus nominal noise [dB], shape ``(len(common_times_sec), len(common_pixels))``.
186
+ f1 : scipy.interpolate.RegularGridInterpolator
187
+ Interpolator of the nominal noise over (azimuth time [s], range pixel), not normalised by `dn`.
188
+ f2 : scipy.interpolate.RegularGridInterpolator
189
+ Interpolator of the reconstructed noise over (azimuth time [s], range pixel), not normalised by `dn`.
190
+ mae : float
191
+ Mean absolute difference [dB], ignoring NaN.
192
+ max_err : float
193
+ Maximum absolute difference [dB], ignoring NaN.
194
+ """
195
+
196
+ times1, pixels_list1, noise_list = read_noise_xml(noise_file)
197
+ times2, pixels_list2, denoise_list = read_noise_xml(denoise_file)
198
+
199
+ common_pixels, times1_sec, times2_sec, common_times_sec = build_common_grid(times1, pixels_list1, times2, pixels_list2)
200
+
201
+ interp_noise = interpolate_noise(pixels_list1, noise_list, common_pixels)
202
+ interp_denoise = interpolate_noise(pixels_list2, denoise_list, common_pixels)
203
+
204
+ f1 = RegularGridInterpolator(
205
+ (times1_sec, common_pixels),
206
+ interp_noise,
207
+ bounds_error=False,
208
+ fill_value=np.nan,
209
+ )
210
+ f2 = RegularGridInterpolator(
211
+ (times2_sec, common_pixels),
212
+ interp_denoise,
213
+ bounds_error=False,
214
+ fill_value=np.nan,
215
+ )
216
+
217
+ T, P = np.meshgrid(common_times_sec, common_pixels, indexing="ij")
218
+
219
+ noise = f1(np.stack([T, P], axis=-1)) / (dn**2)
220
+ denoise = f2(np.stack([T, P], axis=-1)) / (dn**2)
221
+
222
+ noise[noise == 0] = np.nan
223
+ denoise[denoise == 0] = np.nan
224
+
225
+ diff_db = linear_to_db(np.maximum(np.abs(denoise), 1e-12)) - linear_to_db(np.maximum(np.abs(noise), 1e-12))
226
+
227
+ mae = np.nanmean(np.abs(diff_db))
228
+ max_err = np.nanmax(np.abs(diff_db))
229
+
230
+ return common_pixels, common_times_sec, diff_db, f1, f2, mae, max_err
231
+
232
+
233
+ def calculate_error(nominal_noise_filepath, denoise_filepath):
234
+ """
235
+ Compute the mean and maximum absolute error between a nominal and a reconstructed noise XML file.
236
+
237
+ The acquisition mode and product type, used to select the digital number in `DN_VALUES`, are derived from the name
238
+ of the SAFE product containing the nominal noise file (three directory levels up).
239
+
240
+ Parameters
241
+ ----------
242
+ nominal_noise_filepath : pathlib.Path
243
+ Nominal noise XML file, inside a SAFE product.
244
+ denoise_filepath : pathlib.Path
245
+ Reconstructed noise XML file.
246
+
247
+ Returns
248
+ -------
249
+ mean_error : float
250
+ Mean absolute difference [dB].
251
+ max_error : float
252
+ Maximum absolute difference [dB].
253
+ """
254
+
255
+ product_name = nominal_noise_filepath.parent.parent.parent.stem
256
+ mode = product_name[4:6]
257
+ prod_type = product_name[7:10]
258
+ dn = DN_VALUES.get((mode, prod_type))
259
+
260
+ common_pixels, common_times_sec, diff_db, f1, f2, mean_error, max_error = compare_noise(nominal_noise_filepath, denoise_filepath, dn)
261
+
262
+ return mean_error, max_error