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/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