xcheck 0.1.0__tar.gz
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.
- xcheck-0.1.0/PKG-INFO +229 -0
- xcheck-0.1.0/README.md +210 -0
- xcheck-0.1.0/pyproject.toml +34 -0
- xcheck-0.1.0/setup.cfg +4 -0
- xcheck-0.1.0/setup.py +4 -0
- xcheck-0.1.0/src/xcheck/__init__.py +12 -0
- xcheck-0.1.0/src/xcheck/xcheck.py +304 -0
- xcheck-0.1.0/src/xcheck.egg-info/PKG-INFO +229 -0
- xcheck-0.1.0/src/xcheck.egg-info/SOURCES.txt +11 -0
- xcheck-0.1.0/src/xcheck.egg-info/dependency_links.txt +1 -0
- xcheck-0.1.0/src/xcheck.egg-info/entry_points.txt +2 -0
- xcheck-0.1.0/src/xcheck.egg-info/requires.txt +5 -0
- xcheck-0.1.0/src/xcheck.egg-info/top_level.txt +1 -0
xcheck-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A package for cross-checking radargram layers using dot product similarity.
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: matplotlib
|
|
16
|
+
Requires-Dist: scipy
|
|
17
|
+
Requires-Dist: pandas
|
|
18
|
+
Requires-Dist: cartopy
|
|
19
|
+
|
|
20
|
+
# XCheck — Consistency-Based Validation of Englacial Layer Annotations
|
|
21
|
+
|
|
22
|
+
A Python implementation of the radargram layer-matching framework from:
|
|
23
|
+
|
|
24
|
+
> Hassan, Muhammad Behroze; Tama, Bayu Adhi; Purushotham, Sanjay;
|
|
25
|
+
> Janeja, Vandana P. **XCheck: A Consistency-Based Validation Framework
|
|
26
|
+
> for Englacial Layers Annotations.**
|
|
27
|
+
> <https://par.nsf.gov/biblio/10673248> ·
|
|
28
|
+
> DOI: [10.1109/ICDMW69685.2025.00015](https://doi.org/10.1109/ICDMW69685.2025.00015)
|
|
29
|
+
|
|
30
|
+
XCheck validates whether annotated englacial layers are *consistent*
|
|
31
|
+
between two radargrams that observe the same ice — either two
|
|
32
|
+
**consecutive** flight segments (overlap found automatically via GPS time)
|
|
33
|
+
or two **intersecting** flight paths (crossing point supplied via a CSV).
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **GPS Overlap Detection**: Automatically finds temporally overlapping segments between two radargrams using log-transformed GPS timestamps.
|
|
40
|
+
- **Data Loading & Masking**: Loads `.mat` radargram and ground truth layer files and converts annotated layers into binary depth masks.
|
|
41
|
+
- **Geometric Alignment**: Adjusts radargram columns by surface elevation before comparison to correct for topographic variation.
|
|
42
|
+
- **Layer Continuity Matching**: Dot-product similarity algorithm that identifies corresponding layers between two radargrams at their crossing or overlap point.
|
|
43
|
+
- **Flexible Input**: Accepts a single radargram pair via CLI flags, a batch CSV of pairs, or a pre-computed intersections CSV.
|
|
44
|
+
- **Visualization**: Map plots of GPS overlap regions and side-by-side mask views with matched layer annotations.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install xcheck
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### NDH_PythonTools (required)
|
|
55
|
+
|
|
56
|
+
XCheck uses [NDH_PythonTools](https://github.com/nholschuh/NDH_PythonTools)
|
|
57
|
+
for `.mat` file loading and radar data processing. Because that repository
|
|
58
|
+
is not a standard pip package, it must be installed manually:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/nholschuh/NDH_PythonTools.git
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then add its **parent directory** to `PYTHONPATH` before running xcheck:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Linux / macOS
|
|
68
|
+
export PYTHONPATH="/path/to/parent/of/NDH_PythonTools"
|
|
69
|
+
|
|
70
|
+
# Windows PowerShell
|
|
71
|
+
$env:PYTHONPATH = "C:\path\to\parent\of\NDH_PythonTools"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
### Layer matching only (no NDH_PythonTools needed)
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import numpy as np
|
|
82
|
+
from xcheck import match_layers_with_dot_product
|
|
83
|
+
|
|
84
|
+
m1 = np.zeros((6, 5)); m1[[1, 3, 5], :] = 1
|
|
85
|
+
m2 = np.zeros((6, 5)); m2[[1, 3, 5], :] = 1
|
|
86
|
+
|
|
87
|
+
matches = match_layers_with_dot_product(m1, m2, max_distance=1,
|
|
88
|
+
window_size=3, dp_threshold=1)
|
|
89
|
+
print(matches) # [(1, 1), (3, 3), (5, 5)]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Python API (requires NDH_PythonTools + data)
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from xcheck import (
|
|
96
|
+
load_and_process_radargram,
|
|
97
|
+
load_log_gps_time,
|
|
98
|
+
find_overlapping_intervals,
|
|
99
|
+
extract_and_adjust_mask_columns_for_location,
|
|
100
|
+
match_layers_with_dot_product,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Load two consecutive radargrams
|
|
104
|
+
r1, m1, d1 = load_and_process_radargram("Data_20120429_01_026.mat",
|
|
105
|
+
layer_dir="./Nick-layer-data-mat/",
|
|
106
|
+
radar_dir="./Nick-raw-radargram-mat/")
|
|
107
|
+
r2, m2, d2 = load_and_process_radargram("Data_20120429_01_027.mat",
|
|
108
|
+
layer_dir="./Nick-layer-data-mat/",
|
|
109
|
+
radar_dir="./Nick-raw-radargram-mat/")
|
|
110
|
+
|
|
111
|
+
# Find GPS overlap and derive crossing point from midpoint
|
|
112
|
+
import numpy as np
|
|
113
|
+
log_t1 = load_log_gps_time("./Nick-raw-radargram-mat/Data_20120429_01_026.mat")
|
|
114
|
+
log_t2 = load_log_gps_time("./Nick-raw-radargram-mat/Data_20120429_01_027.mat")
|
|
115
|
+
overlaps1, overlaps2 = find_overlapping_intervals(log_t1, log_t2)
|
|
116
|
+
|
|
117
|
+
for iv1, iv2 in zip(overlaps1, overlaps2):
|
|
118
|
+
mid1 = (iv1[0] + iv1[1]) // 2
|
|
119
|
+
mid2 = (iv2[0] + iv2[1]) // 2
|
|
120
|
+
mc1 = extract_and_adjust_mask_columns_for_location(
|
|
121
|
+
r1['Longitude'][mid1], r1['Latitude'][mid1], r1, m1, d1, 20)
|
|
122
|
+
mc2 = extract_and_adjust_mask_columns_for_location(
|
|
123
|
+
r2['Longitude'][mid2], r2['Latitude'][mid2], r2, m2, d2, 20)
|
|
124
|
+
matches = match_layers_with_dot_product(mc1, mc2)
|
|
125
|
+
print(f"Matched layers: {matches}")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## CLI
|
|
131
|
+
|
|
132
|
+
### Single pair — consecutive (no crossing CSV needed)
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
xcheck --r1 Data_20120429_01_026.mat --r2 Data_20120429_01_027.mat \
|
|
136
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
137
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Single pair — intersecting (crossing looked up from CSV)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
xcheck --r1 Data_20120330_01_004.mat --r2 Data_20120511_01_054.mat \
|
|
144
|
+
--intersections_csv ./Intersections_2012.csv \
|
|
145
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
146
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Single pair — intersecting with explicit crossing coordinates
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
xcheck --r1 Data_20120330_01_004.mat --r2 Data_20120511_01_054.mat \
|
|
153
|
+
--lat 78.909416 --lon -61.804612 \
|
|
154
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
155
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Batch mode (all pairs in a CSV)
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
xcheck --intersections_csv ./Intersections_2012.csv \
|
|
162
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
163
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### All CLI arguments
|
|
167
|
+
|
|
168
|
+
| Argument | Default | Description |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `--r1`, `--r2` | — | Single-pair mode: filenames of the two radargrams |
|
|
171
|
+
| `--lat`, `--lon` | — | Crossing coordinates for single-pair mode |
|
|
172
|
+
| `--csv_path` | — | Batch CSV with columns `Radargram 1`, `Radargram 2`, `Latitude`, `Longitude` |
|
|
173
|
+
| `--intersections_csv` | — | Pre-computed intersections CSV (used alone or to look up coordinates) |
|
|
174
|
+
| `--layer_dir` | required | Directory of layer `.mat` files |
|
|
175
|
+
| `--radar_dir` | required | Directory of raw radargram `.mat` files |
|
|
176
|
+
| `--layer_prefix` | `Layer_` | Filename prefix for layer files |
|
|
177
|
+
| `--cols_side` | 20 / 3 | Columns extracted each side of crossing (consecutive / non-consecutive) |
|
|
178
|
+
| `--max_distance` | 4 / 3 | Max depth-pixel gap between candidate layer rows |
|
|
179
|
+
| `--window_size` | 7 / 3 | Dot product window width (must be odd) |
|
|
180
|
+
| `--dp_threshold` | 4 / 1 | Minimum dot product score to confirm a match |
|
|
181
|
+
| `--plot_overlaps` | off | Plot GPS overlap regions on a map |
|
|
182
|
+
|
|
183
|
+
Default pairs shown as **consecutive / non-consecutive**.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Default thresholds by radargram type
|
|
188
|
+
|
|
189
|
+
| Parameter | Consecutive | Non-consecutive |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| `cols_side` | 20 (41 columns) | 3 (7 columns) |
|
|
192
|
+
| `max_distance` | 4 px | 3 px |
|
|
193
|
+
| `window_size` | 7 | 3 |
|
|
194
|
+
| `dp_threshold` | 4 | 1 |
|
|
195
|
+
|
|
196
|
+
These are selected automatically. Override any of them via CLI flags.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Methodological note
|
|
201
|
+
|
|
202
|
+
The GPS overlap detection compares **`log(GPS_time)`** between two
|
|
203
|
+
radargrams with an absolute tolerance of `1e-8`. Because
|
|
204
|
+
`d(log t) ≈ dt/t` and GPS times are ~1×10⁹ seconds, this tolerance
|
|
205
|
+
corresponds to matching raw timestamps within roughly 10 seconds.
|
|
206
|
+
Consecutive flight segments (e.g. `_026.mat` → `_027.mat`) share a
|
|
207
|
+
small tail/head of overlapping traces, which is where the crossing point
|
|
208
|
+
is derived. Non-consecutive intersecting radargrams do not share GPS
|
|
209
|
+
timestamps; their crossing point must be supplied via `--intersections_csv`
|
|
210
|
+
or `--lat`/`--lon`.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Project structure
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
src/xcheck/
|
|
218
|
+
xcheck.py Core algorithms: loading, masking, GPS overlap,
|
|
219
|
+
column extraction, layer matching, plotting, CLI
|
|
220
|
+
__init__.py Public API exports
|
|
221
|
+
pyproject.toml Build metadata and dependencies
|
|
222
|
+
README.md
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
xcheck-0.1.0/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# XCheck — Consistency-Based Validation of Englacial Layer Annotations
|
|
2
|
+
|
|
3
|
+
A Python implementation of the radargram layer-matching framework from:
|
|
4
|
+
|
|
5
|
+
> Hassan, Muhammad Behroze; Tama, Bayu Adhi; Purushotham, Sanjay;
|
|
6
|
+
> Janeja, Vandana P. **XCheck: A Consistency-Based Validation Framework
|
|
7
|
+
> for Englacial Layers Annotations.**
|
|
8
|
+
> <https://par.nsf.gov/biblio/10673248> ·
|
|
9
|
+
> DOI: [10.1109/ICDMW69685.2025.00015](https://doi.org/10.1109/ICDMW69685.2025.00015)
|
|
10
|
+
|
|
11
|
+
XCheck validates whether annotated englacial layers are *consistent*
|
|
12
|
+
between two radargrams that observe the same ice — either two
|
|
13
|
+
**consecutive** flight segments (overlap found automatically via GPS time)
|
|
14
|
+
or two **intersecting** flight paths (crossing point supplied via a CSV).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **GPS Overlap Detection**: Automatically finds temporally overlapping segments between two radargrams using log-transformed GPS timestamps.
|
|
21
|
+
- **Data Loading & Masking**: Loads `.mat` radargram and ground truth layer files and converts annotated layers into binary depth masks.
|
|
22
|
+
- **Geometric Alignment**: Adjusts radargram columns by surface elevation before comparison to correct for topographic variation.
|
|
23
|
+
- **Layer Continuity Matching**: Dot-product similarity algorithm that identifies corresponding layers between two radargrams at their crossing or overlap point.
|
|
24
|
+
- **Flexible Input**: Accepts a single radargram pair via CLI flags, a batch CSV of pairs, or a pre-computed intersections CSV.
|
|
25
|
+
- **Visualization**: Map plots of GPS overlap regions and side-by-side mask views with matched layer annotations.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install xcheck
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### NDH_PythonTools (required)
|
|
36
|
+
|
|
37
|
+
XCheck uses [NDH_PythonTools](https://github.com/nholschuh/NDH_PythonTools)
|
|
38
|
+
for `.mat` file loading and radar data processing. Because that repository
|
|
39
|
+
is not a standard pip package, it must be installed manually:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git clone https://github.com/nholschuh/NDH_PythonTools.git
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then add its **parent directory** to `PYTHONPATH` before running xcheck:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Linux / macOS
|
|
49
|
+
export PYTHONPATH="/path/to/parent/of/NDH_PythonTools"
|
|
50
|
+
|
|
51
|
+
# Windows PowerShell
|
|
52
|
+
$env:PYTHONPATH = "C:\path\to\parent\of\NDH_PythonTools"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
### Layer matching only (no NDH_PythonTools needed)
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import numpy as np
|
|
63
|
+
from xcheck import match_layers_with_dot_product
|
|
64
|
+
|
|
65
|
+
m1 = np.zeros((6, 5)); m1[[1, 3, 5], :] = 1
|
|
66
|
+
m2 = np.zeros((6, 5)); m2[[1, 3, 5], :] = 1
|
|
67
|
+
|
|
68
|
+
matches = match_layers_with_dot_product(m1, m2, max_distance=1,
|
|
69
|
+
window_size=3, dp_threshold=1)
|
|
70
|
+
print(matches) # [(1, 1), (3, 3), (5, 5)]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Python API (requires NDH_PythonTools + data)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from xcheck import (
|
|
77
|
+
load_and_process_radargram,
|
|
78
|
+
load_log_gps_time,
|
|
79
|
+
find_overlapping_intervals,
|
|
80
|
+
extract_and_adjust_mask_columns_for_location,
|
|
81
|
+
match_layers_with_dot_product,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Load two consecutive radargrams
|
|
85
|
+
r1, m1, d1 = load_and_process_radargram("Data_20120429_01_026.mat",
|
|
86
|
+
layer_dir="./Nick-layer-data-mat/",
|
|
87
|
+
radar_dir="./Nick-raw-radargram-mat/")
|
|
88
|
+
r2, m2, d2 = load_and_process_radargram("Data_20120429_01_027.mat",
|
|
89
|
+
layer_dir="./Nick-layer-data-mat/",
|
|
90
|
+
radar_dir="./Nick-raw-radargram-mat/")
|
|
91
|
+
|
|
92
|
+
# Find GPS overlap and derive crossing point from midpoint
|
|
93
|
+
import numpy as np
|
|
94
|
+
log_t1 = load_log_gps_time("./Nick-raw-radargram-mat/Data_20120429_01_026.mat")
|
|
95
|
+
log_t2 = load_log_gps_time("./Nick-raw-radargram-mat/Data_20120429_01_027.mat")
|
|
96
|
+
overlaps1, overlaps2 = find_overlapping_intervals(log_t1, log_t2)
|
|
97
|
+
|
|
98
|
+
for iv1, iv2 in zip(overlaps1, overlaps2):
|
|
99
|
+
mid1 = (iv1[0] + iv1[1]) // 2
|
|
100
|
+
mid2 = (iv2[0] + iv2[1]) // 2
|
|
101
|
+
mc1 = extract_and_adjust_mask_columns_for_location(
|
|
102
|
+
r1['Longitude'][mid1], r1['Latitude'][mid1], r1, m1, d1, 20)
|
|
103
|
+
mc2 = extract_and_adjust_mask_columns_for_location(
|
|
104
|
+
r2['Longitude'][mid2], r2['Latitude'][mid2], r2, m2, d2, 20)
|
|
105
|
+
matches = match_layers_with_dot_product(mc1, mc2)
|
|
106
|
+
print(f"Matched layers: {matches}")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## CLI
|
|
112
|
+
|
|
113
|
+
### Single pair — consecutive (no crossing CSV needed)
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
xcheck --r1 Data_20120429_01_026.mat --r2 Data_20120429_01_027.mat \
|
|
117
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
118
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Single pair — intersecting (crossing looked up from CSV)
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
xcheck --r1 Data_20120330_01_004.mat --r2 Data_20120511_01_054.mat \
|
|
125
|
+
--intersections_csv ./Intersections_2012.csv \
|
|
126
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
127
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Single pair — intersecting with explicit crossing coordinates
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
xcheck --r1 Data_20120330_01_004.mat --r2 Data_20120511_01_054.mat \
|
|
134
|
+
--lat 78.909416 --lon -61.804612 \
|
|
135
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
136
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Batch mode (all pairs in a CSV)
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
xcheck --intersections_csv ./Intersections_2012.csv \
|
|
143
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
144
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### All CLI arguments
|
|
148
|
+
|
|
149
|
+
| Argument | Default | Description |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `--r1`, `--r2` | — | Single-pair mode: filenames of the two radargrams |
|
|
152
|
+
| `--lat`, `--lon` | — | Crossing coordinates for single-pair mode |
|
|
153
|
+
| `--csv_path` | — | Batch CSV with columns `Radargram 1`, `Radargram 2`, `Latitude`, `Longitude` |
|
|
154
|
+
| `--intersections_csv` | — | Pre-computed intersections CSV (used alone or to look up coordinates) |
|
|
155
|
+
| `--layer_dir` | required | Directory of layer `.mat` files |
|
|
156
|
+
| `--radar_dir` | required | Directory of raw radargram `.mat` files |
|
|
157
|
+
| `--layer_prefix` | `Layer_` | Filename prefix for layer files |
|
|
158
|
+
| `--cols_side` | 20 / 3 | Columns extracted each side of crossing (consecutive / non-consecutive) |
|
|
159
|
+
| `--max_distance` | 4 / 3 | Max depth-pixel gap between candidate layer rows |
|
|
160
|
+
| `--window_size` | 7 / 3 | Dot product window width (must be odd) |
|
|
161
|
+
| `--dp_threshold` | 4 / 1 | Minimum dot product score to confirm a match |
|
|
162
|
+
| `--plot_overlaps` | off | Plot GPS overlap regions on a map |
|
|
163
|
+
|
|
164
|
+
Default pairs shown as **consecutive / non-consecutive**.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Default thresholds by radargram type
|
|
169
|
+
|
|
170
|
+
| Parameter | Consecutive | Non-consecutive |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `cols_side` | 20 (41 columns) | 3 (7 columns) |
|
|
173
|
+
| `max_distance` | 4 px | 3 px |
|
|
174
|
+
| `window_size` | 7 | 3 |
|
|
175
|
+
| `dp_threshold` | 4 | 1 |
|
|
176
|
+
|
|
177
|
+
These are selected automatically. Override any of them via CLI flags.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Methodological note
|
|
182
|
+
|
|
183
|
+
The GPS overlap detection compares **`log(GPS_time)`** between two
|
|
184
|
+
radargrams with an absolute tolerance of `1e-8`. Because
|
|
185
|
+
`d(log t) ≈ dt/t` and GPS times are ~1×10⁹ seconds, this tolerance
|
|
186
|
+
corresponds to matching raw timestamps within roughly 10 seconds.
|
|
187
|
+
Consecutive flight segments (e.g. `_026.mat` → `_027.mat`) share a
|
|
188
|
+
small tail/head of overlapping traces, which is where the crossing point
|
|
189
|
+
is derived. Non-consecutive intersecting radargrams do not share GPS
|
|
190
|
+
timestamps; their crossing point must be supplied via `--intersections_csv`
|
|
191
|
+
or `--lat`/`--lon`.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Project structure
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
src/xcheck/
|
|
199
|
+
xcheck.py Core algorithms: loading, masking, GPS overlap,
|
|
200
|
+
column extraction, layer matching, plotting, CLI
|
|
201
|
+
__init__.py Public API exports
|
|
202
|
+
pyproject.toml Build metadata and dependencies
|
|
203
|
+
README.md
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xcheck"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Your Name", email="your.email@example.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "A package for cross-checking radargram layers using dot product similarity."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"numpy",
|
|
24
|
+
"matplotlib",
|
|
25
|
+
"scipy",
|
|
26
|
+
"pandas",
|
|
27
|
+
"cartopy",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
xcheck = "xcheck.xcheck:main"
|
xcheck-0.1.0/setup.cfg
ADDED
xcheck-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .xcheck import (
|
|
2
|
+
load_and_process_radargram,
|
|
3
|
+
load_log_gps_time,
|
|
4
|
+
find_overlapping_intervals,
|
|
5
|
+
load_data,
|
|
6
|
+
adjust_column_by_surface_elevation,
|
|
7
|
+
extract_and_adjust_mask_columns_for_location,
|
|
8
|
+
match_layers_with_dot_product,
|
|
9
|
+
plot_overlaps,
|
|
10
|
+
plot_columns_with_labels,
|
|
11
|
+
main
|
|
12
|
+
)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
import argparse
|
|
7
|
+
import scipy.io
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import cartopy.crs as ccrs
|
|
10
|
+
from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
|
|
11
|
+
|
|
12
|
+
# Register NDH_PythonTools as a package without executing its __init__.py,
|
|
13
|
+
# which auto-imports all submodules including ones with optional heavy deps (pyvista, etc.)
|
|
14
|
+
def _register_ndh_package():
|
|
15
|
+
for p in sys.path:
|
|
16
|
+
candidate = os.path.join(p, 'NDH_PythonTools')
|
|
17
|
+
if os.path.isdir(candidate):
|
|
18
|
+
if 'NDH_PythonTools' not in sys.modules:
|
|
19
|
+
pkg = types.ModuleType('NDH_PythonTools')
|
|
20
|
+
pkg.__path__ = [candidate]
|
|
21
|
+
pkg.__package__ = 'NDH_PythonTools'
|
|
22
|
+
sys.modules['NDH_PythonTools'] = pkg
|
|
23
|
+
return
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"NDH_PythonTools not found on sys.path. "
|
|
26
|
+
"Clone https://github.com/nholschuh/NDH_PythonTools and add its parent directory to PYTHONPATH."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_register_ndh_package()
|
|
30
|
+
|
|
31
|
+
from NDH_PythonTools.loadmat import loadmat
|
|
32
|
+
from NDH_PythonTools.radar_load import radar_load
|
|
33
|
+
|
|
34
|
+
def load_and_process_radargram(file_name, layer_dir, radar_dir, layer_prefix="Layer_"):
|
|
35
|
+
"""Loads radargram data and ground truth layers, then creates a binary mask."""
|
|
36
|
+
layer_fn = os.path.join(layer_dir, layer_prefix + file_name)
|
|
37
|
+
radar_fn = os.path.join(radar_dir, file_name)
|
|
38
|
+
|
|
39
|
+
groundtruth = loadmat(layer_fn)
|
|
40
|
+
groundtruth_dists = groundtruth['layer_info'][()][2]
|
|
41
|
+
groundtruth_layers = groundtruth['layer_info'][()][5].T
|
|
42
|
+
radar_data, depth_data = radar_load(radar_fn, 0, 1)
|
|
43
|
+
|
|
44
|
+
ground_truth_mask_nick = np.zeros_like(depth_data['new_data'])
|
|
45
|
+
|
|
46
|
+
for layer in groundtruth_layers:
|
|
47
|
+
for idx, depth in enumerate(layer):
|
|
48
|
+
if not np.isnan(depth):
|
|
49
|
+
depth_idx = np.argmin(np.abs(depth_data['depth_axis'] - depth))
|
|
50
|
+
dist_idx = np.argmin(np.abs(radar_data['distance'] - groundtruth_dists[idx]))
|
|
51
|
+
if 0 <= depth_idx < ground_truth_mask_nick.shape[0] and 0 <= dist_idx < ground_truth_mask_nick.shape[1]:
|
|
52
|
+
ground_truth_mask_nick[depth_idx, dist_idx] = 1
|
|
53
|
+
return radar_data, ground_truth_mask_nick, depth_data
|
|
54
|
+
|
|
55
|
+
def load_log_gps_time(file_path):
|
|
56
|
+
"""Loads GPS time from a .mat file and returns its natural logarithm."""
|
|
57
|
+
radar_data = scipy.io.loadmat(file_path)
|
|
58
|
+
gps_time = radar_data['GPS_time'][0]
|
|
59
|
+
return np.log(gps_time)
|
|
60
|
+
|
|
61
|
+
def find_overlapping_intervals(log_gps_time1, log_gps_time2, tolerance=1e-8):
|
|
62
|
+
"""Finds overlapping intervals between two logarithmic GPS time series."""
|
|
63
|
+
overlap_intervals1, overlap_intervals2 = [], []
|
|
64
|
+
i, j = 0, 0
|
|
65
|
+
start_i, start_j = None, None
|
|
66
|
+
|
|
67
|
+
while i < len(log_gps_time1) and j < len(log_gps_time2):
|
|
68
|
+
if abs(log_gps_time1[i] - log_gps_time2[j]) < tolerance:
|
|
69
|
+
if start_i is None:
|
|
70
|
+
start_i, start_j = i, j
|
|
71
|
+
i += 1
|
|
72
|
+
j += 1
|
|
73
|
+
else:
|
|
74
|
+
if start_i is not None:
|
|
75
|
+
overlap_intervals1.append((start_i, i - 1))
|
|
76
|
+
overlap_intervals2.append((start_j, j - 1))
|
|
77
|
+
start_i, start_j = None, None
|
|
78
|
+
if log_gps_time1[i] < log_gps_time2[j]: i += 1
|
|
79
|
+
else: j += 1
|
|
80
|
+
if start_i is not None:
|
|
81
|
+
overlap_intervals1.append((start_i, i - 1))
|
|
82
|
+
overlap_intervals2.append((start_j, j - 1))
|
|
83
|
+
return overlap_intervals1, overlap_intervals2
|
|
84
|
+
|
|
85
|
+
def load_data(file_path):
|
|
86
|
+
"""Loads latitude and longitude data from a .mat file."""
|
|
87
|
+
radar_data = scipy.io.loadmat(file_path)
|
|
88
|
+
return radar_data['Latitude'][0], radar_data['Longitude'][0]
|
|
89
|
+
|
|
90
|
+
def adjust_column_by_surface_elevation(depth_data, idx):
|
|
91
|
+
"""Calculates the depth index corresponding to the surface elevation."""
|
|
92
|
+
surface_elevation = depth_data['surface_elev'][idx]
|
|
93
|
+
return np.argmin(np.abs(depth_data['depth_axis'] - surface_elevation))
|
|
94
|
+
|
|
95
|
+
def extract_and_adjust_mask_columns_for_location(longitude, latitude, radar_data, mask, depth_data, columns_before_after):
|
|
96
|
+
"""Extracts and adjusts mask columns around a specific geospatial location."""
|
|
97
|
+
dists = (radar_data['Longitude'] - longitude) ** 2 + (radar_data['Latitude'] - latitude) ** 2
|
|
98
|
+
center_idx = np.argmin(dists)
|
|
99
|
+
start = max(0, center_idx - columns_before_after)
|
|
100
|
+
end = min(mask.shape[1], center_idx + columns_before_after + 1)
|
|
101
|
+
|
|
102
|
+
reference_surface_idx = adjust_column_by_surface_elevation(depth_data, center_idx)
|
|
103
|
+
reference_depth_axis = depth_data['depth_axis']
|
|
104
|
+
adjusted_cols = []
|
|
105
|
+
|
|
106
|
+
for idx in range(start, end):
|
|
107
|
+
current_surface_idx = adjust_column_by_surface_elevation(depth_data, idx)
|
|
108
|
+
current_depth_axis = depth_data['depth_axis']
|
|
109
|
+
depth_diff = reference_depth_axis[reference_surface_idx] - current_depth_axis[current_surface_idx]
|
|
110
|
+
meters_per_pixel = np.mean(np.diff(current_depth_axis))
|
|
111
|
+
pixel_shift = int(round(depth_diff / meters_per_pixel))
|
|
112
|
+
col = mask[:, idx]
|
|
113
|
+
if pixel_shift > 0:
|
|
114
|
+
col_shifted = np.pad(col, (pixel_shift, 0), mode='constant')[:len(col)]
|
|
115
|
+
elif pixel_shift < 0:
|
|
116
|
+
col_shifted = np.pad(col, (0, -pixel_shift), mode='constant')[-pixel_shift:]
|
|
117
|
+
if len(col_shifted) > len(col): col_shifted = col_shifted[:len(col)]
|
|
118
|
+
else: col_shifted = np.pad(col_shifted, (0, len(col) - len(col_shifted)), mode='constant')
|
|
119
|
+
else: col_shifted = col
|
|
120
|
+
adjusted_cols.append(col_shifted)
|
|
121
|
+
|
|
122
|
+
max_len = max(len(col) for col in adjusted_cols)
|
|
123
|
+
for i, col in enumerate(adjusted_cols):
|
|
124
|
+
if len(col) < max_len:
|
|
125
|
+
adjusted_cols[i] = np.pad(col, (0, max_len - len(col)), 'constant')
|
|
126
|
+
return np.column_stack(adjusted_cols)
|
|
127
|
+
|
|
128
|
+
def match_layers_with_dot_product(mask1, mask2, max_distance=4, window_size=7, dp_threshold=4, verbose=True):
|
|
129
|
+
"""Matches layers between two radargram masks using a dot product similarity metric."""
|
|
130
|
+
if window_size % 2 == 0: raise ValueError("window_size must be odd.")
|
|
131
|
+
matches, matched1, matched2 = [], set(), set()
|
|
132
|
+
c1, c2 = mask1.shape[1] // 2, mask2.shape[1] // 2
|
|
133
|
+
hw = window_size // 2
|
|
134
|
+
cols1 = slice(max(c1 - hw, 0), min(c1 + hw + 1, mask1.shape[1]))
|
|
135
|
+
cols2 = slice(max(c2 - hw, 0), min(c2 + hw + 1, mask2.shape[1]))
|
|
136
|
+
|
|
137
|
+
for i in range(mask1.shape[0]):
|
|
138
|
+
if i in matched1 or not np.any(mask1[i, :]): continue
|
|
139
|
+
best_match, best_dist, best_dp = None, max_distance + 1, -1
|
|
140
|
+
for j in range(mask2.shape[0]):
|
|
141
|
+
if j in matched2 or not np.any(mask2[j, :]): continue
|
|
142
|
+
dist = abs(i - j)
|
|
143
|
+
if dist <= max_distance:
|
|
144
|
+
r1, r2 = mask1[i, cols1], mask2[j, cols2]
|
|
145
|
+
if not (r1[hw] == 1 or np.sum(r1) >= 2): continue
|
|
146
|
+
if not (r2[hw] == 1 or np.sum(r2) >= 2): continue
|
|
147
|
+
dp = np.dot(r1, r2)
|
|
148
|
+
if dp >= dp_threshold and dist < best_dist:
|
|
149
|
+
best_match, best_dist, best_dp = j, dist, dp
|
|
150
|
+
if best_match is not None:
|
|
151
|
+
matches.append((i, best_match))
|
|
152
|
+
matched1.add(i); matched2.add(best_match)
|
|
153
|
+
if verbose: print(f"Match: Layer {i} <-> Layer {best_match}")
|
|
154
|
+
return matches
|
|
155
|
+
|
|
156
|
+
def plot_overlaps(lat1, lon1, lat2, lon2, overlap_intervals1, overlap_intervals2):
|
|
157
|
+
ax = plt.figure(figsize=(20, 10)).add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
|
|
158
|
+
ax.coastlines()
|
|
159
|
+
gl = ax.gridlines(draw_labels=True, linewidth=1, color='gray', alpha=0.5, linestyle='--')
|
|
160
|
+
gl.top_labels = gl.right_labels = False
|
|
161
|
+
ax.scatter(lon1, lat1, color='blue', s=10, label='Radargram 1')
|
|
162
|
+
ax.scatter(lon2, lat2, color='red', s=10, label='Radargram 2')
|
|
163
|
+
for (s1, e1), (s2, e2) in zip(overlap_intervals1, overlap_intervals2):
|
|
164
|
+
ax.plot(lon1[s1:e1+1], lat1[s1:e1+1], color='yellow', linewidth=2)
|
|
165
|
+
ax.plot(lon2[s2:e2+1], lat2[s2:e2+1], color='yellow', linewidth=2)
|
|
166
|
+
ax.legend(); plt.show()
|
|
167
|
+
|
|
168
|
+
def plot_columns_with_labels(mask1, mask2, matches, columns_before_after, title1="R1", title2="R2"):
|
|
169
|
+
fig, axs = plt.subplots(1, 2, figsize=(14, 6), sharey=True)
|
|
170
|
+
x_vals = np.arange(-columns_before_after, columns_before_after + 1)
|
|
171
|
+
axs[0].imshow(mask1, cmap='gray', aspect='auto', extent=(x_vals[0], x_vals[-1], mask1.shape[0], 0))
|
|
172
|
+
axs[1].imshow(mask2, cmap='gray', aspect='auto', extent=(x_vals[0], x_vals[-1], mask2.shape[0], 0))
|
|
173
|
+
for i, j in matches:
|
|
174
|
+
axs[0].axhline(i, color='red', linestyle='--'); axs[1].axhline(j, color='red', linestyle='--')
|
|
175
|
+
plt.tight_layout(); plt.show()
|
|
176
|
+
|
|
177
|
+
def _lookup_intersection(intersections_df, file1, file2):
|
|
178
|
+
"""Returns (lon, lat) for a radargram pair from an intersections DataFrame, or None if not found."""
|
|
179
|
+
mask = (
|
|
180
|
+
((intersections_df['Radargram 1'] == file1) & (intersections_df['Radargram 2'] == file2)) |
|
|
181
|
+
((intersections_df['Radargram 2'] == file1) & (intersections_df['Radargram 1'] == file2))
|
|
182
|
+
)
|
|
183
|
+
hit = intersections_df[mask]
|
|
184
|
+
if hit.empty:
|
|
185
|
+
return None
|
|
186
|
+
return hit['Longitude'].iloc[0], hit['Latitude'].iloc[0]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _process_pair(file1, file2, lon, lat, args):
|
|
190
|
+
"""Run GPS overlap detection and layer matching.
|
|
191
|
+
|
|
192
|
+
Non-consecutive/intersecting: crossing point supplied via lon/lat.
|
|
193
|
+
Consecutive: no crossing point — midpoint of each GPS overlap interval is used instead.
|
|
194
|
+
"""
|
|
195
|
+
path1 = os.path.join(args.radar_dir, file1)
|
|
196
|
+
path2 = os.path.join(args.radar_dir, file2)
|
|
197
|
+
|
|
198
|
+
# GPS-based overlap detection
|
|
199
|
+
log_gps1 = load_log_gps_time(path1)
|
|
200
|
+
log_gps2 = load_log_gps_time(path2)
|
|
201
|
+
overlaps1, overlaps2 = find_overlapping_intervals(log_gps1, log_gps2)
|
|
202
|
+
print(f"{file1} <-> {file2}: {len(overlaps1)} GPS overlap interval(s) found")
|
|
203
|
+
for k, ((s1, e1), (s2, e2)) in enumerate(zip(overlaps1, overlaps2)):
|
|
204
|
+
print(f" Interval {k+1}: R1[{s1}:{e1}] R2[{s2}:{e2}] ({e1-s1+1} samples)")
|
|
205
|
+
|
|
206
|
+
if args.plot_overlaps:
|
|
207
|
+
lat1_arr, lon1_arr = load_data(path1)
|
|
208
|
+
lat2_arr, lon2_arr = load_data(path2)
|
|
209
|
+
plot_overlaps(lat1_arr, lon1_arr, lat2_arr, lon2_arr, overlaps1, overlaps2)
|
|
210
|
+
|
|
211
|
+
r1, m1, d1 = load_and_process_radargram(file1, args.layer_dir, args.radar_dir, args.layer_prefix)
|
|
212
|
+
r2, m2, d2 = load_and_process_radargram(file2, args.layer_dir, args.radar_dir, args.layer_prefix)
|
|
213
|
+
|
|
214
|
+
if lon is not None and lat is not None:
|
|
215
|
+
# Non-consecutive: use provided intersection coordinates
|
|
216
|
+
cols = args.cols_side if args.cols_side is not None else 3
|
|
217
|
+
maxd = args.max_distance if args.max_distance is not None else 3
|
|
218
|
+
wsize = args.window_size if args.window_size is not None else 3
|
|
219
|
+
dpth = args.dp_threshold if args.dp_threshold is not None else 1
|
|
220
|
+
mc1 = extract_and_adjust_mask_columns_for_location(lon, lat, r1, m1, d1, cols)
|
|
221
|
+
mc2 = extract_and_adjust_mask_columns_for_location(lon, lat, r2, m2, d2, cols)
|
|
222
|
+
matches = match_layers_with_dot_product(mc1, mc2, max_distance=maxd, window_size=wsize, dp_threshold=dpth)
|
|
223
|
+
print(f"Total Matches: {len(matches)}")
|
|
224
|
+
|
|
225
|
+
elif overlaps1:
|
|
226
|
+
# Consecutive: derive crossing point from midpoint of each GPS overlap interval
|
|
227
|
+
cols = args.cols_side if args.cols_side is not None else 20
|
|
228
|
+
maxd = args.max_distance if args.max_distance is not None else 4
|
|
229
|
+
wsize = args.window_size if args.window_size is not None else 7
|
|
230
|
+
dpth = args.dp_threshold if args.dp_threshold is not None else 4
|
|
231
|
+
for k, (interval1, interval2) in enumerate(zip(overlaps1, overlaps2)):
|
|
232
|
+
mid1 = (interval1[0] + interval1[1]) // 2
|
|
233
|
+
mid2 = (interval2[0] + interval2[1]) // 2
|
|
234
|
+
mc1 = extract_and_adjust_mask_columns_for_location(r1['Longitude'][mid1], r1['Latitude'][mid1], r1, m1, d1, cols)
|
|
235
|
+
mc2 = extract_and_adjust_mask_columns_for_location(r2['Longitude'][mid2], r2['Latitude'][mid2], r2, m2, d2, cols)
|
|
236
|
+
matches = match_layers_with_dot_product(mc1, mc2, max_distance=maxd, window_size=wsize, dp_threshold=dpth)
|
|
237
|
+
print(f"Overlap interval {k+1} — Total Matches: {len(matches)}")
|
|
238
|
+
|
|
239
|
+
else:
|
|
240
|
+
print(f"No crossing coordinates and no GPS overlap found — skipping layer matching.")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main():
|
|
244
|
+
parser = argparse.ArgumentParser(description="XCheck CLI")
|
|
245
|
+
parser.add_argument('--r1', type=str, default=None,
|
|
246
|
+
help="First radargram filename (single-pair mode).")
|
|
247
|
+
parser.add_argument('--r2', type=str, default=None,
|
|
248
|
+
help="Second radargram filename (single-pair mode).")
|
|
249
|
+
parser.add_argument('--lat', type=float, default=None,
|
|
250
|
+
help="Crossing latitude for single-pair mode (optional if --intersections_csv is given).")
|
|
251
|
+
parser.add_argument('--lon', type=float, default=None,
|
|
252
|
+
help="Crossing longitude for single-pair mode (optional if --intersections_csv is given).")
|
|
253
|
+
parser.add_argument('--csv_path', type=str, default=None,
|
|
254
|
+
help="CSV with radargram pairs. Must include Longitude/Latitude columns unless --intersections_csv is also provided.")
|
|
255
|
+
parser.add_argument('--intersections_csv', type=str, default=None,
|
|
256
|
+
help="Optional CSV with pre-computed intersection coordinates (columns: Radargram 1, Radargram 2, Latitude, Longitude). "
|
|
257
|
+
"When provided alone, all pairs in it are processed. When combined with --csv_path, coordinates are looked up here.")
|
|
258
|
+
parser.add_argument('--layer_dir', type=str, required=True)
|
|
259
|
+
parser.add_argument('--radar_dir', type=str, required=True)
|
|
260
|
+
parser.add_argument('--cols_side', type=int, default=None,
|
|
261
|
+
help="Columns extracted each side of crossing (default: 20 for consecutive, 3 for non-consecutive).")
|
|
262
|
+
parser.add_argument('--max_distance', type=int, default=None,
|
|
263
|
+
help="Max depth-pixel gap between candidate layer rows (default: 4 consecutive, 3 non-consecutive).")
|
|
264
|
+
parser.add_argument('--window_size', type=int, default=None,
|
|
265
|
+
help="Odd column window width for dot product (default: 7 consecutive, 3 non-consecutive).")
|
|
266
|
+
parser.add_argument('--dp_threshold', type=float, default=None,
|
|
267
|
+
help="Minimum dot product to count as a match (default: 4 consecutive, 1 non-consecutive).")
|
|
268
|
+
parser.add_argument('--layer_prefix', type=str, default='Layer_',
|
|
269
|
+
help="Filename prefix for layer files (default: 'Layer_')")
|
|
270
|
+
parser.add_argument('--plot_overlaps', action='store_true', help="Plot GPS overlap regions on a map")
|
|
271
|
+
args = parser.parse_args()
|
|
272
|
+
|
|
273
|
+
# --- Single-pair mode ---
|
|
274
|
+
if args.r1 and args.r2:
|
|
275
|
+
intersections_df = pd.read_csv(args.intersections_csv) if args.intersections_csv else None
|
|
276
|
+
lon, lat = args.lon, args.lat
|
|
277
|
+
if lon is None and intersections_df is not None:
|
|
278
|
+
result = _lookup_intersection(intersections_df, args.r1, args.r2)
|
|
279
|
+
if result is not None:
|
|
280
|
+
lon, lat = result
|
|
281
|
+
_process_pair(args.r1, args.r2, lon, lat, args)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# --- Batch mode ---
|
|
285
|
+
if args.csv_path is None and args.intersections_csv is None:
|
|
286
|
+
parser.error("Provide --r1/--r2 for a single pair, or --csv_path/--intersections_csv for batch mode.")
|
|
287
|
+
|
|
288
|
+
intersections_df = pd.read_csv(args.intersections_csv) if args.intersections_csv else None
|
|
289
|
+
df = pd.read_csv(args.csv_path) if args.csv_path else intersections_df
|
|
290
|
+
|
|
291
|
+
for _, row in df.iterrows():
|
|
292
|
+
file1, file2 = row['Radargram 1'], row['Radargram 2']
|
|
293
|
+
if intersections_df is not None:
|
|
294
|
+
result = _lookup_intersection(intersections_df, file1, file2)
|
|
295
|
+
if result is None:
|
|
296
|
+
print(f"Skipping {file1} <-> {file2}: no intersection found in --intersections_csv")
|
|
297
|
+
continue
|
|
298
|
+
lon, lat = result
|
|
299
|
+
else:
|
|
300
|
+
lon, lat = row['Longitude'], row['Latitude']
|
|
301
|
+
_process_pair(file1, file2, lon, lat, args)
|
|
302
|
+
|
|
303
|
+
if __name__ == '__main__':
|
|
304
|
+
main()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A package for cross-checking radargram layers using dot product similarity.
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: matplotlib
|
|
16
|
+
Requires-Dist: scipy
|
|
17
|
+
Requires-Dist: pandas
|
|
18
|
+
Requires-Dist: cartopy
|
|
19
|
+
|
|
20
|
+
# XCheck — Consistency-Based Validation of Englacial Layer Annotations
|
|
21
|
+
|
|
22
|
+
A Python implementation of the radargram layer-matching framework from:
|
|
23
|
+
|
|
24
|
+
> Hassan, Muhammad Behroze; Tama, Bayu Adhi; Purushotham, Sanjay;
|
|
25
|
+
> Janeja, Vandana P. **XCheck: A Consistency-Based Validation Framework
|
|
26
|
+
> for Englacial Layers Annotations.**
|
|
27
|
+
> <https://par.nsf.gov/biblio/10673248> ·
|
|
28
|
+
> DOI: [10.1109/ICDMW69685.2025.00015](https://doi.org/10.1109/ICDMW69685.2025.00015)
|
|
29
|
+
|
|
30
|
+
XCheck validates whether annotated englacial layers are *consistent*
|
|
31
|
+
between two radargrams that observe the same ice — either two
|
|
32
|
+
**consecutive** flight segments (overlap found automatically via GPS time)
|
|
33
|
+
or two **intersecting** flight paths (crossing point supplied via a CSV).
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **GPS Overlap Detection**: Automatically finds temporally overlapping segments between two radargrams using log-transformed GPS timestamps.
|
|
40
|
+
- **Data Loading & Masking**: Loads `.mat` radargram and ground truth layer files and converts annotated layers into binary depth masks.
|
|
41
|
+
- **Geometric Alignment**: Adjusts radargram columns by surface elevation before comparison to correct for topographic variation.
|
|
42
|
+
- **Layer Continuity Matching**: Dot-product similarity algorithm that identifies corresponding layers between two radargrams at their crossing or overlap point.
|
|
43
|
+
- **Flexible Input**: Accepts a single radargram pair via CLI flags, a batch CSV of pairs, or a pre-computed intersections CSV.
|
|
44
|
+
- **Visualization**: Map plots of GPS overlap regions and side-by-side mask views with matched layer annotations.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install xcheck
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### NDH_PythonTools (required)
|
|
55
|
+
|
|
56
|
+
XCheck uses [NDH_PythonTools](https://github.com/nholschuh/NDH_PythonTools)
|
|
57
|
+
for `.mat` file loading and radar data processing. Because that repository
|
|
58
|
+
is not a standard pip package, it must be installed manually:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/nholschuh/NDH_PythonTools.git
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then add its **parent directory** to `PYTHONPATH` before running xcheck:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Linux / macOS
|
|
68
|
+
export PYTHONPATH="/path/to/parent/of/NDH_PythonTools"
|
|
69
|
+
|
|
70
|
+
# Windows PowerShell
|
|
71
|
+
$env:PYTHONPATH = "C:\path\to\parent\of\NDH_PythonTools"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
### Layer matching only (no NDH_PythonTools needed)
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import numpy as np
|
|
82
|
+
from xcheck import match_layers_with_dot_product
|
|
83
|
+
|
|
84
|
+
m1 = np.zeros((6, 5)); m1[[1, 3, 5], :] = 1
|
|
85
|
+
m2 = np.zeros((6, 5)); m2[[1, 3, 5], :] = 1
|
|
86
|
+
|
|
87
|
+
matches = match_layers_with_dot_product(m1, m2, max_distance=1,
|
|
88
|
+
window_size=3, dp_threshold=1)
|
|
89
|
+
print(matches) # [(1, 1), (3, 3), (5, 5)]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Python API (requires NDH_PythonTools + data)
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from xcheck import (
|
|
96
|
+
load_and_process_radargram,
|
|
97
|
+
load_log_gps_time,
|
|
98
|
+
find_overlapping_intervals,
|
|
99
|
+
extract_and_adjust_mask_columns_for_location,
|
|
100
|
+
match_layers_with_dot_product,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Load two consecutive radargrams
|
|
104
|
+
r1, m1, d1 = load_and_process_radargram("Data_20120429_01_026.mat",
|
|
105
|
+
layer_dir="./Nick-layer-data-mat/",
|
|
106
|
+
radar_dir="./Nick-raw-radargram-mat/")
|
|
107
|
+
r2, m2, d2 = load_and_process_radargram("Data_20120429_01_027.mat",
|
|
108
|
+
layer_dir="./Nick-layer-data-mat/",
|
|
109
|
+
radar_dir="./Nick-raw-radargram-mat/")
|
|
110
|
+
|
|
111
|
+
# Find GPS overlap and derive crossing point from midpoint
|
|
112
|
+
import numpy as np
|
|
113
|
+
log_t1 = load_log_gps_time("./Nick-raw-radargram-mat/Data_20120429_01_026.mat")
|
|
114
|
+
log_t2 = load_log_gps_time("./Nick-raw-radargram-mat/Data_20120429_01_027.mat")
|
|
115
|
+
overlaps1, overlaps2 = find_overlapping_intervals(log_t1, log_t2)
|
|
116
|
+
|
|
117
|
+
for iv1, iv2 in zip(overlaps1, overlaps2):
|
|
118
|
+
mid1 = (iv1[0] + iv1[1]) // 2
|
|
119
|
+
mid2 = (iv2[0] + iv2[1]) // 2
|
|
120
|
+
mc1 = extract_and_adjust_mask_columns_for_location(
|
|
121
|
+
r1['Longitude'][mid1], r1['Latitude'][mid1], r1, m1, d1, 20)
|
|
122
|
+
mc2 = extract_and_adjust_mask_columns_for_location(
|
|
123
|
+
r2['Longitude'][mid2], r2['Latitude'][mid2], r2, m2, d2, 20)
|
|
124
|
+
matches = match_layers_with_dot_product(mc1, mc2)
|
|
125
|
+
print(f"Matched layers: {matches}")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## CLI
|
|
131
|
+
|
|
132
|
+
### Single pair — consecutive (no crossing CSV needed)
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
xcheck --r1 Data_20120429_01_026.mat --r2 Data_20120429_01_027.mat \
|
|
136
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
137
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Single pair — intersecting (crossing looked up from CSV)
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
xcheck --r1 Data_20120330_01_004.mat --r2 Data_20120511_01_054.mat \
|
|
144
|
+
--intersections_csv ./Intersections_2012.csv \
|
|
145
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
146
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Single pair — intersecting with explicit crossing coordinates
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
xcheck --r1 Data_20120330_01_004.mat --r2 Data_20120511_01_054.mat \
|
|
153
|
+
--lat 78.909416 --lon -61.804612 \
|
|
154
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
155
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Batch mode (all pairs in a CSV)
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
xcheck --intersections_csv ./Intersections_2012.csv \
|
|
162
|
+
--layer_dir ./Nick-layer-data-mat/ \
|
|
163
|
+
--radar_dir ./Nick-raw-radargram-mat/
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### All CLI arguments
|
|
167
|
+
|
|
168
|
+
| Argument | Default | Description |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `--r1`, `--r2` | — | Single-pair mode: filenames of the two radargrams |
|
|
171
|
+
| `--lat`, `--lon` | — | Crossing coordinates for single-pair mode |
|
|
172
|
+
| `--csv_path` | — | Batch CSV with columns `Radargram 1`, `Radargram 2`, `Latitude`, `Longitude` |
|
|
173
|
+
| `--intersections_csv` | — | Pre-computed intersections CSV (used alone or to look up coordinates) |
|
|
174
|
+
| `--layer_dir` | required | Directory of layer `.mat` files |
|
|
175
|
+
| `--radar_dir` | required | Directory of raw radargram `.mat` files |
|
|
176
|
+
| `--layer_prefix` | `Layer_` | Filename prefix for layer files |
|
|
177
|
+
| `--cols_side` | 20 / 3 | Columns extracted each side of crossing (consecutive / non-consecutive) |
|
|
178
|
+
| `--max_distance` | 4 / 3 | Max depth-pixel gap between candidate layer rows |
|
|
179
|
+
| `--window_size` | 7 / 3 | Dot product window width (must be odd) |
|
|
180
|
+
| `--dp_threshold` | 4 / 1 | Minimum dot product score to confirm a match |
|
|
181
|
+
| `--plot_overlaps` | off | Plot GPS overlap regions on a map |
|
|
182
|
+
|
|
183
|
+
Default pairs shown as **consecutive / non-consecutive**.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Default thresholds by radargram type
|
|
188
|
+
|
|
189
|
+
| Parameter | Consecutive | Non-consecutive |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| `cols_side` | 20 (41 columns) | 3 (7 columns) |
|
|
192
|
+
| `max_distance` | 4 px | 3 px |
|
|
193
|
+
| `window_size` | 7 | 3 |
|
|
194
|
+
| `dp_threshold` | 4 | 1 |
|
|
195
|
+
|
|
196
|
+
These are selected automatically. Override any of them via CLI flags.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Methodological note
|
|
201
|
+
|
|
202
|
+
The GPS overlap detection compares **`log(GPS_time)`** between two
|
|
203
|
+
radargrams with an absolute tolerance of `1e-8`. Because
|
|
204
|
+
`d(log t) ≈ dt/t` and GPS times are ~1×10⁹ seconds, this tolerance
|
|
205
|
+
corresponds to matching raw timestamps within roughly 10 seconds.
|
|
206
|
+
Consecutive flight segments (e.g. `_026.mat` → `_027.mat`) share a
|
|
207
|
+
small tail/head of overlapping traces, which is where the crossing point
|
|
208
|
+
is derived. Non-consecutive intersecting radargrams do not share GPS
|
|
209
|
+
timestamps; their crossing point must be supplied via `--intersections_csv`
|
|
210
|
+
or `--lat`/`--lon`.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Project structure
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
src/xcheck/
|
|
218
|
+
xcheck.py Core algorithms: loading, masking, GPS overlap,
|
|
219
|
+
column extraction, layer matching, plotting, CLI
|
|
220
|
+
__init__.py Public API exports
|
|
221
|
+
pyproject.toml Build metadata and dependencies
|
|
222
|
+
README.md
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
src/xcheck/__init__.py
|
|
5
|
+
src/xcheck/xcheck.py
|
|
6
|
+
src/xcheck.egg-info/PKG-INFO
|
|
7
|
+
src/xcheck.egg-info/SOURCES.txt
|
|
8
|
+
src/xcheck.egg-info/dependency_links.txt
|
|
9
|
+
src/xcheck.egg-info/entry_points.txt
|
|
10
|
+
src/xcheck.egg-info/requires.txt
|
|
11
|
+
src/xcheck.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xcheck
|