svc-processing 0.1.1__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.
- pipeline/__init__.py +0 -0
- pipeline/cli.py +92 -0
- pipeline/processor.py +582 -0
- pipeline/resampler.py +495 -0
- pipeline/run_config.py +303 -0
- pipeline/runner.py +216 -0
- pipeline/sig_processor.py +258 -0
- svc_processing-0.1.1.dist-info/METADATA +250 -0
- svc_processing-0.1.1.dist-info/RECORD +13 -0
- svc_processing-0.1.1.dist-info/WHEEL +5 -0
- svc_processing-0.1.1.dist-info/entry_points.txt +2 -0
- svc_processing-0.1.1.dist-info/licenses/LICENSE +674 -0
- svc_processing-0.1.1.dist-info/top_level.txt +1 -0
pipeline/resampler.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Pure-Python resampler replacing merge_resample_sig.R.
|
|
2
|
+
|
|
3
|
+
Faithfully replicates the spectrolab R pipeline:
|
|
4
|
+
read_spectra → guess_splice_at → match_sensors → smooth → resample(fwhm=10)
|
|
5
|
+
|
|
6
|
+
This is an independent reimplementation of the *algorithm* published in the
|
|
7
|
+
spectrolab R package (GPL-3; Meireles, Schweiger & Cavender-Bares, 2017,
|
|
8
|
+
doi:10.5281/zenodo.3934575). It contains no spectrolab source code — only the
|
|
9
|
+
algorithm was reimplemented in NumPy/SciPy and verified numerically against
|
|
10
|
+
spectrolab output. spectrolab internal function names cited in the docstrings
|
|
11
|
+
below (e.g. i_bands, i_make_fwhm) describe what each step replicates; they are
|
|
12
|
+
references, not copied code.
|
|
13
|
+
|
|
14
|
+
SVC .sig files store multiple sensor segments sequentially in one file, separated
|
|
15
|
+
by backward wavelength jumps. spectrolab detects these segments and applies a
|
|
16
|
+
linearly-varying multiplicative correction per segment so that the sensors match
|
|
17
|
+
at each splice boundary. specdal does not understand this structure, so this
|
|
18
|
+
module reads .sig files directly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import warnings
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import pandas as pd
|
|
29
|
+
from scipy.cluster.vq import kmeans as _scipy_kmeans, vq as _scipy_vq
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── spectrolab replication constants ────────────────────────────────────────
|
|
33
|
+
_FWHM_NM = 10.0 # spectrolab resample fwhm=10
|
|
34
|
+
_INTERP_WVL = (5.0, 2.0) # spectrolab interpolate_wvl default
|
|
35
|
+
_FIXED_SENSOR = 2 # spectrolab fixed_sensor when 2 splices
|
|
36
|
+
_BAND_MIN = 400
|
|
37
|
+
_BAND_MAX = 2500
|
|
38
|
+
_N_SENSORS = 3 # HR-1024i has three detectors → three sweeps
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── .sig file reader ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def _read_sig(path: Path) -> tuple[np.ndarray, np.ndarray]:
|
|
44
|
+
"""
|
|
45
|
+
Read a SVC .sig file and return (wavelengths, reflectances) in file order.
|
|
46
|
+
|
|
47
|
+
The .sig data section has four space-separated columns:
|
|
48
|
+
wavelength ref_radiance tgt_radiance pct_reflect
|
|
49
|
+
|
|
50
|
+
Sensor segments appear sequentially; segment N+1 starts where the wavelength
|
|
51
|
+
sequence jumps backward relative to segment N.
|
|
52
|
+
|
|
53
|
+
Exact-duplicate wavelengths (which occur in the sensor overlap regions) receive
|
|
54
|
+
a tiny perturbation matching spectrolab's i_bands():
|
|
55
|
+
correction = 1.2357e-05 * min(|diff(non_duplicate_bands)|)
|
|
56
|
+
This causes the sensor overlap trimming logic to keep both the last band of
|
|
57
|
+
sensor N and the first band of sensor N+1 when their nominal wavelengths match.
|
|
58
|
+
|
|
59
|
+
Trailing junk rows
|
|
60
|
+
------------------
|
|
61
|
+
A correct SVC HR-1024i scan is exactly three sweeps (one per detector), so the
|
|
62
|
+
data section should contain three ascending segments. Some exported .sig files
|
|
63
|
+
carry corrupt extra rows after those three sweeps -- e.g. a stray sample near
|
|
64
|
+
"5 nm" or a duplicated fragment of an earlier detector. Left in place these show
|
|
65
|
+
up as spurious diagonal/horizontal lines in raw plots and skew reference-panel
|
|
66
|
+
detection. We therefore keep only the first three segments and drop anything
|
|
67
|
+
after them, warning so the bad file is not silently trimmed (see
|
|
68
|
+
``_drop_trailing_junk``). Well-formed files are unaffected.
|
|
69
|
+
"""
|
|
70
|
+
with open(path) as fh:
|
|
71
|
+
lines = fh.readlines()
|
|
72
|
+
|
|
73
|
+
data_start = next(i for i, line in enumerate(lines) if line.strip() == "data=")
|
|
74
|
+
wls, rfs = [], []
|
|
75
|
+
for line in lines[data_start + 1:]:
|
|
76
|
+
parts = line.split()
|
|
77
|
+
if len(parts) >= 4:
|
|
78
|
+
try:
|
|
79
|
+
wls.append(float(parts[0]))
|
|
80
|
+
rfs.append(float(parts[3]) / 100.0)
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
wls_arr = np.array(wls, dtype=float)
|
|
85
|
+
rfs_arr = np.array(rfs, dtype=float)
|
|
86
|
+
|
|
87
|
+
wls_arr, rfs_arr = _drop_trailing_junk(wls_arr, rfs_arr, source=path)
|
|
88
|
+
|
|
89
|
+
# Replicate spectrolab i_bands(): add a tiny correction to exact duplicates
|
|
90
|
+
seen: dict[float, bool] = {}
|
|
91
|
+
dup_positions: list[int] = []
|
|
92
|
+
for i, w in enumerate(wls_arr):
|
|
93
|
+
key = float(w)
|
|
94
|
+
if key in seen:
|
|
95
|
+
dup_positions.append(i)
|
|
96
|
+
else:
|
|
97
|
+
seen[key] = True
|
|
98
|
+
|
|
99
|
+
if dup_positions:
|
|
100
|
+
non_dup_mask = np.ones(len(wls_arr), dtype=bool)
|
|
101
|
+
non_dup_mask[dup_positions] = False
|
|
102
|
+
min_spacing = float(np.min(np.abs(np.diff(wls_arr[non_dup_mask]))))
|
|
103
|
+
correction = 1.2357e-05 * min_spacing
|
|
104
|
+
for pos in dup_positions:
|
|
105
|
+
wls_arr[pos] += correction
|
|
106
|
+
|
|
107
|
+
return wls_arr, rfs_arr
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _sensor_segment_indices(wls: np.ndarray) -> list[tuple[int, int]]:
|
|
111
|
+
"""
|
|
112
|
+
Return (start, end) index pairs for each monotonically-increasing sensor segment.
|
|
113
|
+
|
|
114
|
+
Matches spectrolab's i_find_sensor_overlap_bounds: a new segment starts
|
|
115
|
+
wherever wavelength[i] < wavelength[i-1].
|
|
116
|
+
"""
|
|
117
|
+
decreases = np.where(np.diff(wls) < 0)[0] + 1 # positions of backward jumps
|
|
118
|
+
starts = np.concatenate([[0], decreases])
|
|
119
|
+
ends = np.concatenate([decreases, [len(wls)]])
|
|
120
|
+
return list(zip(starts.tolist(), ends.tolist()))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _drop_trailing_junk(
|
|
124
|
+
wls: np.ndarray,
|
|
125
|
+
rfs: np.ndarray,
|
|
126
|
+
*,
|
|
127
|
+
source: Path,
|
|
128
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
129
|
+
"""
|
|
130
|
+
Discard corrupt rows that some .sig exports append after a valid scan.
|
|
131
|
+
|
|
132
|
+
Background for non-SVC readers
|
|
133
|
+
------------------------------
|
|
134
|
+
The SVC HR-1024i has three detectors (VNIR + two SWIR). Each detector sweeps
|
|
135
|
+
from a low wavelength up to a high one, so a correct .sig file lays the data
|
|
136
|
+
out as exactly three runs of increasing wavelength, one after another:
|
|
137
|
+
|
|
138
|
+
detector 1: ~339 -> 1010 nm
|
|
139
|
+
detector 2: ~971 -> 1911 nm (starts low again -> new segment)
|
|
140
|
+
detector 3: ~1892 -> 2520 nm (starts low again -> new segment)
|
|
141
|
+
|
|
142
|
+
``_sensor_segment_indices`` detects each "starts low again" as the boundary of
|
|
143
|
+
a new segment. A clean file therefore yields three segments. We have observed
|
|
144
|
+
exported files that contain extra rows tacked on after the third sweep -- for
|
|
145
|
+
example a single sample reported near "5 nm" (far below the instrument's range)
|
|
146
|
+
or a duplicated chunk of an earlier detector. Those rows are not real
|
|
147
|
+
measurements; they appear to be an export/instrument glitch.
|
|
148
|
+
|
|
149
|
+
Why this matters
|
|
150
|
+
----------------
|
|
151
|
+
The junk rows are kept "in file order", so when raw spectra are plotted the line
|
|
152
|
+
jumps to the out-of-place points and back, drawing spurious diagonal/horizontal
|
|
153
|
+
streaks across the chart. They also distort summary statistics used elsewhere
|
|
154
|
+
(e.g. the median-reflectance test that flags reference panels).
|
|
155
|
+
|
|
156
|
+
What we do
|
|
157
|
+
----------
|
|
158
|
+
Keep only the first ``_N_SENSORS`` (3) segments and drop everything after them.
|
|
159
|
+
A well-formed file has exactly three segments, so this is a no-op there. When we
|
|
160
|
+
do trim something we emit a ``UserWarning`` rather than trimming silently, so a
|
|
161
|
+
genuinely malformed file is visible to whoever is running the pipeline instead
|
|
162
|
+
of being quietly "cleaned up".
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
wls, rfs : the wavelength and reflectance arrays, in original file order.
|
|
167
|
+
source : path to the .sig file, used only to make the warning message useful.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
The (possibly shortened) ``(wls, rfs)`` arrays.
|
|
172
|
+
"""
|
|
173
|
+
segments = _sensor_segment_indices(wls)
|
|
174
|
+
if len(segments) <= _N_SENSORS:
|
|
175
|
+
return wls, rfs
|
|
176
|
+
|
|
177
|
+
valid_end = segments[_N_SENSORS][0] # first index past the third sweep
|
|
178
|
+
dropped = len(wls) - valid_end
|
|
179
|
+
warnings.warn(
|
|
180
|
+
f"{Path(source).name}: found {len(segments)} sensor sweeps but the "
|
|
181
|
+
f"HR-1024i has only {_N_SENSORS}. Dropping {dropped} trailing row(s) "
|
|
182
|
+
f"(wavelengths {wls[valid_end]:.1f}-{wls[-1]:.1f} nm) as corrupt export "
|
|
183
|
+
f"data. The valid scan ({valid_end} bands) is kept.",
|
|
184
|
+
UserWarning,
|
|
185
|
+
stacklevel=2,
|
|
186
|
+
)
|
|
187
|
+
return wls[:valid_end], rfs[:valid_end]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _guess_splice_at(segments: list[tuple[int, int]], wls: np.ndarray) -> list[float]:
|
|
191
|
+
"""
|
|
192
|
+
Compute splice wavelengths using spectrolab's formula:
|
|
193
|
+
splice[i] = (sensor[i+1].min_wl * 2*(i+1) + sensor[i].max_wl) / (2*(i+1) + 1)
|
|
194
|
+
|
|
195
|
+
where i is 1-based (i=1 for first splice, i=2 for second…).
|
|
196
|
+
"""
|
|
197
|
+
splices = []
|
|
198
|
+
for i, ((_, end_i), (start_j, _)) in enumerate(zip(segments[:-1], segments[1:]), start=1):
|
|
199
|
+
scalar = 2 * i
|
|
200
|
+
min_next = wls[start_j]
|
|
201
|
+
max_curr = wls[end_i - 1]
|
|
202
|
+
splice = (min_next * scalar + max_curr) / (scalar + 1)
|
|
203
|
+
splices.append(splice)
|
|
204
|
+
return splices
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _trim_and_assign(
|
|
208
|
+
segments: list[tuple[int, int]],
|
|
209
|
+
wls: np.ndarray,
|
|
210
|
+
splices: list[float],
|
|
211
|
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
212
|
+
"""
|
|
213
|
+
Replicate spectrolab's i_trim_sensor_overlap: clip each sensor at the splice
|
|
214
|
+
boundaries so there is no wavelength overlap between adjacent sensors.
|
|
215
|
+
|
|
216
|
+
Returns a list of (wavelength_indices, sensor_id) per sensor, matching the
|
|
217
|
+
order used by match_sensors.
|
|
218
|
+
"""
|
|
219
|
+
# Build per-sensor index arrays
|
|
220
|
+
sensor_idx = [np.arange(s, e) for s, e in segments]
|
|
221
|
+
|
|
222
|
+
for i, splice in enumerate(splices):
|
|
223
|
+
# Clip sensor[i+1] on the left: keep wavelengths >= splice
|
|
224
|
+
right_mask = wls[sensor_idx[i + 1]] >= splice
|
|
225
|
+
sensor_idx[i + 1] = sensor_idx[i + 1][right_mask]
|
|
226
|
+
|
|
227
|
+
# Clip sensor[i] on the right: keep wavelengths < first of trimmed sensor[i+1]
|
|
228
|
+
if len(sensor_idx[i + 1]) > 0:
|
|
229
|
+
min_right = wls[sensor_idx[i + 1][0]]
|
|
230
|
+
left_mask = wls[sensor_idx[i]] < min_right
|
|
231
|
+
sensor_idx[i] = sensor_idx[i][left_mask]
|
|
232
|
+
|
|
233
|
+
return sensor_idx
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _apply_match_sensors(
|
|
237
|
+
wls: np.ndarray,
|
|
238
|
+
rfs: np.ndarray,
|
|
239
|
+
sensor_idx: list[np.ndarray],
|
|
240
|
+
splices: list[float],
|
|
241
|
+
fixed_sensor: int = _FIXED_SENSOR,
|
|
242
|
+
interp_wvl: tuple = _INTERP_WVL,
|
|
243
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
244
|
+
"""
|
|
245
|
+
Replicate spectrolab's match_sensors.
|
|
246
|
+
|
|
247
|
+
When the .sig file contains actual sensor overlap (backward wavelength jumps)
|
|
248
|
+
and there are multiple splices, spectrolab sets iter=1 — meaning only sensor 1
|
|
249
|
+
is corrected. Sensor 2 (fixed) and sensor 3 stay unchanged.
|
|
250
|
+
|
|
251
|
+
The correction for sensor 1 is a linearly-varying multiplicative factor:
|
|
252
|
+
factor = 1.0 at the start of sensor 1's wavelength range
|
|
253
|
+
factor = q at the end of sensor 1's wavelength range
|
|
254
|
+
where q = mean(fixed_sensor_at_splice) / mean(sensor1_at_splice).
|
|
255
|
+
|
|
256
|
+
fixed_sensor is 1-based (spectrolab default = 2).
|
|
257
|
+
"""
|
|
258
|
+
corrected_rfs = rfs.copy()
|
|
259
|
+
|
|
260
|
+
# Only the first splice correction is applied when there are multiple splices
|
|
261
|
+
# and the .sig file has overlap data (spectrolab: iter=1 branch).
|
|
262
|
+
# We still compute all splice factors for correctness, but apply only the first.
|
|
263
|
+
n_apply = 1 if len(splices) > 1 else len(splices)
|
|
264
|
+
|
|
265
|
+
fixed_0 = fixed_sensor - 1 # 0-based index of fixed sensor
|
|
266
|
+
|
|
267
|
+
for z in range(n_apply):
|
|
268
|
+
splice = splices[z]
|
|
269
|
+
hw = interp_wvl[z] if z < len(interp_wvl) else interp_wvl[-1]
|
|
270
|
+
low, high = splice - hw, splice + hw
|
|
271
|
+
|
|
272
|
+
left_idx = sensor_idx[z][
|
|
273
|
+
(wls[sensor_idx[z]] >= low) & (wls[sensor_idx[z]] <= high)
|
|
274
|
+
]
|
|
275
|
+
right_idx = sensor_idx[z + 1][
|
|
276
|
+
(wls[sensor_idx[z + 1]] >= low) & (wls[sensor_idx[z + 1]] <= high)
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
if len(left_idx) == 0:
|
|
280
|
+
left_idx = sensor_idx[z][[-1]]
|
|
281
|
+
if len(right_idx) == 0:
|
|
282
|
+
right_idx = sensor_idx[z + 1][[0]]
|
|
283
|
+
|
|
284
|
+
mean_left = rfs[left_idx].mean()
|
|
285
|
+
mean_right = rfs[right_idx].mean()
|
|
286
|
+
|
|
287
|
+
# Determine which side is fixed and compute the scaling factor q
|
|
288
|
+
if z + 1 == fixed_0: # fixed sensor is on the right at this splice
|
|
289
|
+
q = mean_right / mean_left if mean_left != 0 else 1.0
|
|
290
|
+
# Scale sensor z (left): factor from 1.0 at start → q at end
|
|
291
|
+
idx = sensor_idx[z]
|
|
292
|
+
w = wls[idx]
|
|
293
|
+
factors = np.interp(w, [w.min(), w.max()], [1.0, q])
|
|
294
|
+
corrected_rfs[idx] = rfs[idx] * factors
|
|
295
|
+
else: # fixed sensor is on the left at this splice
|
|
296
|
+
q = mean_left / mean_right if mean_right != 0 else 1.0
|
|
297
|
+
# Scale sensor z+1 (right): factor from q at start → 1.0 at end
|
|
298
|
+
idx = sensor_idx[z + 1]
|
|
299
|
+
w = wls[idx]
|
|
300
|
+
factors = np.interp(w, [w.min(), w.max()], [q, 1.0])
|
|
301
|
+
corrected_rfs[idx] = rfs[idx] * factors
|
|
302
|
+
|
|
303
|
+
# Merge all sensor bands in wavelength order
|
|
304
|
+
all_idx = np.concatenate(sensor_idx)
|
|
305
|
+
order = np.argsort(wls[all_idx])
|
|
306
|
+
merged_wls = wls[all_idx][order]
|
|
307
|
+
merged_rfs = corrected_rfs[all_idx][order]
|
|
308
|
+
|
|
309
|
+
return merged_wls, merged_rfs
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _smooth_fwhm(wls: np.ndarray, rfs: np.ndarray) -> np.ndarray:
|
|
313
|
+
"""
|
|
314
|
+
Replicate spectrolab smooth(method='gaussian') → smooth_fwhm().
|
|
315
|
+
|
|
316
|
+
Mirrors spectrolab internals exactly:
|
|
317
|
+
1. fwhm_from_band_diff — per-band FWHM from local band spacing
|
|
318
|
+
2. i_make_fwhm (return_type='old') — Gaussian-weighted mean of neighbour FWHMs
|
|
319
|
+
3. kmeans(k=3) — quantise into 3 bandwidth clusters (one per sensor)
|
|
320
|
+
4. Double the FWHM (smooth_fwhm multiplies make_fwhm result by 2)
|
|
321
|
+
5. Gaussian resample to the same wavelength grid with per-band sigma
|
|
322
|
+
"""
|
|
323
|
+
diffs = np.diff(wls)
|
|
324
|
+
fwhm_per_band = np.concatenate([[diffs[0]], diffs]) # fwhm_from_band_diff
|
|
325
|
+
|
|
326
|
+
sigma0 = fwhm_per_band / (2.0 * np.sqrt(2.0 * np.log(2.0)))
|
|
327
|
+
|
|
328
|
+
# Pairwise distance matrix — reused for both the fwhm averaging and the resample
|
|
329
|
+
d = wls[:, None] - wls[None, :] # shape (n, n): d[i, j] = wls[i] - wls[j]
|
|
330
|
+
|
|
331
|
+
# i_make_fwhm with return_type='old': Gaussian-weighted average of fwhm_per_band
|
|
332
|
+
g = np.exp(-0.5 * (d / sigma0[None, :]) ** 2) # sigma0[j] for target j
|
|
333
|
+
fwhm_old_new = (g * fwhm_per_band[:, None]).sum(axis=0) / g.sum(axis=0)
|
|
334
|
+
|
|
335
|
+
# kmeans(k=3): quantise bandwidth into 3 clusters (mirrors spectrolab make_fwhm k=3)
|
|
336
|
+
k = min(3, len(np.unique(np.round(fwhm_old_new, 4))))
|
|
337
|
+
codebook, _ = _scipy_kmeans(fwhm_old_new, k)
|
|
338
|
+
labels, _ = _scipy_vq(fwhm_old_new, codebook)
|
|
339
|
+
smooth_fwhm_vals = 2.0 * codebook[labels] # step 4: double the FWHM
|
|
340
|
+
|
|
341
|
+
# Gaussian resample to the same grid with per-band sigma
|
|
342
|
+
sigma_s = smooth_fwhm_vals / (2.0 * np.sqrt(2.0 * np.log(2.0)))
|
|
343
|
+
g2 = np.exp(-0.5 * (d / sigma_s[None, :]) ** 2)
|
|
344
|
+
return (g2 * rfs[:, None]).sum(axis=0) / g2.sum(axis=0)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _gaussian_resample(
|
|
348
|
+
wls: np.ndarray,
|
|
349
|
+
rfs: np.ndarray,
|
|
350
|
+
target: np.ndarray,
|
|
351
|
+
sigma: float,
|
|
352
|
+
) -> np.ndarray:
|
|
353
|
+
"""
|
|
354
|
+
Gaussian-weighted resampling: replicates spectrolab's resample(fwhm=10).
|
|
355
|
+
|
|
356
|
+
For each target wavelength w_t:
|
|
357
|
+
weight_i = exp(-0.5 * ((wl_i - w_t) / sigma)^2)
|
|
358
|
+
output(w_t) = sum(weight_i * rf_i) / sum(weight_i)
|
|
359
|
+
"""
|
|
360
|
+
d = wls[:, None] - target[None, :] # (n_input, n_target)
|
|
361
|
+
w = np.exp(-0.5 * (d / sigma) ** 2)
|
|
362
|
+
return (w * rfs[:, None]).sum(axis=0) / w.sum(axis=0)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ── public API ───────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
@dataclass(frozen=True)
|
|
368
|
+
class ProcessedSpectrum:
|
|
369
|
+
"""All intermediate arrays and diagnostics produced for one .sig file."""
|
|
370
|
+
|
|
371
|
+
sample_name: str
|
|
372
|
+
raw_wavelengths: np.ndarray
|
|
373
|
+
raw_reflectance: np.ndarray
|
|
374
|
+
segments: tuple[tuple[int, int], ...]
|
|
375
|
+
splice_wavelengths: tuple[float, ...]
|
|
376
|
+
corrected_wavelengths: np.ndarray
|
|
377
|
+
corrected_reflectance: np.ndarray
|
|
378
|
+
output_wavelengths: np.ndarray
|
|
379
|
+
output_reflectance: np.ndarray
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _sigma_from_fwhm(fwhm_nm: float) -> float:
|
|
383
|
+
"""Convert full width at half maximum to Gaussian sigma."""
|
|
384
|
+
return fwhm_nm / (2.0 * np.sqrt(2.0 * np.log(2.0)))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def process_sig_file(
|
|
388
|
+
path: Path,
|
|
389
|
+
*,
|
|
390
|
+
band_min: int = _BAND_MIN,
|
|
391
|
+
band_max: int = _BAND_MAX,
|
|
392
|
+
fwhm_nm: float = _FWHM_NM,
|
|
393
|
+
fixed_sensor: int = _FIXED_SENSOR,
|
|
394
|
+
interp_wvl: tuple[float, ...] = _INTERP_WVL,
|
|
395
|
+
) -> ProcessedSpectrum:
|
|
396
|
+
"""Process one .sig file through the parity-verified resampling path."""
|
|
397
|
+
sig_path = Path(path)
|
|
398
|
+
wls, rfs = _read_sig(sig_path)
|
|
399
|
+
|
|
400
|
+
segments = _sensor_segment_indices(wls)
|
|
401
|
+
splices: list[float] = []
|
|
402
|
+
|
|
403
|
+
if len(segments) > 1:
|
|
404
|
+
splices = _guess_splice_at(segments, wls)
|
|
405
|
+
sensor_idx = _trim_and_assign(segments, wls, splices)
|
|
406
|
+
fixed = min(fixed_sensor, len(segments))
|
|
407
|
+
merged_wls, merged_rfs = _apply_match_sensors(
|
|
408
|
+
wls, rfs, sensor_idx, splices,
|
|
409
|
+
fixed_sensor=fixed, interp_wvl=interp_wvl,
|
|
410
|
+
)
|
|
411
|
+
else:
|
|
412
|
+
merged_wls = wls
|
|
413
|
+
merged_rfs = rfs
|
|
414
|
+
|
|
415
|
+
smoothed_rfs = _smooth_fwhm(merged_wls, merged_rfs)
|
|
416
|
+
target_wls = np.arange(band_min, band_max + 1, dtype=float)
|
|
417
|
+
sigma = _sigma_from_fwhm(fwhm_nm)
|
|
418
|
+
resampled = _gaussian_resample(merged_wls, smoothed_rfs, target_wls, sigma=sigma)
|
|
419
|
+
|
|
420
|
+
return ProcessedSpectrum(
|
|
421
|
+
sample_name=sig_path.stem,
|
|
422
|
+
raw_wavelengths=wls,
|
|
423
|
+
raw_reflectance=rfs,
|
|
424
|
+
segments=tuple(segments),
|
|
425
|
+
splice_wavelengths=tuple(splices),
|
|
426
|
+
corrected_wavelengths=merged_wls,
|
|
427
|
+
corrected_reflectance=merged_rfs,
|
|
428
|
+
output_wavelengths=target_wls,
|
|
429
|
+
output_reflectance=resampled,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def resample_spectra(
|
|
434
|
+
input_dir: Path,
|
|
435
|
+
output_dir: Path,
|
|
436
|
+
output_filename: str,
|
|
437
|
+
*,
|
|
438
|
+
band_min: int = _BAND_MIN,
|
|
439
|
+
band_max: int = _BAND_MAX,
|
|
440
|
+
fwhm_nm: float = _FWHM_NM,
|
|
441
|
+
fixed_sensor: int = _FIXED_SENSOR,
|
|
442
|
+
interp_wvl: tuple = _INTERP_WVL,
|
|
443
|
+
) -> Path:
|
|
444
|
+
"""Load .sig files, replicate spectrolab's pipeline, and write merged CSV.
|
|
445
|
+
|
|
446
|
+
Pipeline (mirrors spectrolab exactly):
|
|
447
|
+
read_spectra – parse .sig data section, detect sensor segments
|
|
448
|
+
guess_splice_at – compute splice wavelengths from segment bounds
|
|
449
|
+
match_sensors – linearly-varying multiplicative correction per sensor
|
|
450
|
+
smooth() – Gaussian smooth with per-band sigma from local band spacing
|
|
451
|
+
resample(fwhm=10) – Gaussian-weighted resampling to 400–2500 nm
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
input_dir: Directory containing processed .sig files.
|
|
455
|
+
output_dir: Directory where the output CSV will be written.
|
|
456
|
+
output_filename: File name for the output CSV.
|
|
457
|
+
band_min: First wavelength of the output grid (default 400 nm).
|
|
458
|
+
band_max: Last wavelength of the output grid (default 2500 nm).
|
|
459
|
+
fwhm_nm: FWHM of the final Gaussian resample kernel (default 10 nm).
|
|
460
|
+
fixed_sensor: 1-based index of the sensor held fixed during match_sensors
|
|
461
|
+
(default 2). Changing this breaks R/spectrolab parity.
|
|
462
|
+
interp_wvl: Half-window widths (nm) used around each splice for the
|
|
463
|
+
match_sensors correction (default (5.0, 2.0)).
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Path to the written CSV.
|
|
467
|
+
"""
|
|
468
|
+
input_dir = Path(input_dir)
|
|
469
|
+
output_dir = Path(output_dir)
|
|
470
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
471
|
+
|
|
472
|
+
sig_files = sorted(input_dir.glob("*.sig"))
|
|
473
|
+
if not sig_files:
|
|
474
|
+
raise ValueError(f"No .sig files found in {input_dir}")
|
|
475
|
+
|
|
476
|
+
target_wls = np.arange(band_min, band_max + 1, dtype=float)
|
|
477
|
+
rows: dict[str, np.ndarray] = {}
|
|
478
|
+
|
|
479
|
+
for sig_path in sig_files:
|
|
480
|
+
processed = process_sig_file(
|
|
481
|
+
sig_path,
|
|
482
|
+
band_min=band_min,
|
|
483
|
+
band_max=band_max,
|
|
484
|
+
fwhm_nm=fwhm_nm,
|
|
485
|
+
fixed_sensor=fixed_sensor,
|
|
486
|
+
interp_wvl=interp_wvl,
|
|
487
|
+
)
|
|
488
|
+
rows[processed.sample_name] = processed.output_reflectance
|
|
489
|
+
|
|
490
|
+
df = pd.DataFrame(rows, index=target_wls.astype(int)).T
|
|
491
|
+
df.index.name = "sample_name"
|
|
492
|
+
|
|
493
|
+
output_path = output_dir / output_filename
|
|
494
|
+
df.to_csv(output_path)
|
|
495
|
+
return output_path
|