Skip to content

Commit cd242b0

Browse files
authored
Merge pull request #136 from at88mph/cutout-bounds-fix
Cutout bounds fix
2 parents 913c61e + 0e5124b commit cd242b0

17 files changed

Lines changed: 612 additions & 127 deletions

cadc-data-ops-fits/README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,20 @@ The `cadc-data-ops-fits` library depends on the NASA led NOM TAM FITS library
55
(https://github.com/nom-tam-fits/nom-tam-fits) version 1.15.3 (or newer).
66

77
## Building it
8-
You may use the provided Gradle Wrapper, or provide your own Gradle (< 7) installation.
8+
Use the repository Gradle Wrapper (8.x). From the repo root:
99

1010
```sh
11-
$ ../gradlew -i clean build
11+
../gradlew -i :cadc-data-ops-fits:clean :cadc-data-ops-fits:build
1212
```
1313

14+
### WCS native dependency (`cadc-wcs`)
15+
16+
World-coordinate cutouts use **`cadc-wcs`**, which loads a JNI library bundled in the JAR plus the system **WCSLib** C library at runtime. **`mavenLocal()` is listed before `mavenCentral()`** in this module so you can **`publishToMavenLocal`** a locally built `cadc-wcs` (with a JNI binary for your OS) and override the artifact from Central.
17+
18+
**Linux:** Install WCSLib from your distribution (e.g. `wcslib-dev` on Debian/Ubuntu) or a prefix build. **macOS:** Install via Homebrew, MacPorts, or a prefix build ([WCSLIB](https://www.atnf.csiro.au/computing/software/wcs/)).
19+
20+
If tests fail with `WCSLibInitializationException` or `UnsatisfiedLinkError`, build JNI in the [`opencadc/wcs`](https://github.com/opencadc/wcs) `cadc-wcs` project (`./gradlew -c settings-jni.gradle copyJniToResources` after installing WCSLib), then `./gradlew :cadc-wcs:publishToMavenLocal` from that repo. Optional overrides when building JNI: environment variables **`WCSLIB_LIB`** (path to `libwcs.so` / `libwcs.dylib`) or **`WCSLIB_LIB_DIR`**, or Gradle **`-Pwcslib.lib=...` / `-Pwcslib.libDir=...`**.
21+
1422
## Cutout API
1523
This library supports the commonly used cutout syntax to extract a sub-image from an Image HDU.
1624

cadc-data-ops-fits/build.gradle

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,24 @@ repositories {
1111

1212
apply from: '../opencadc.gradle'
1313

14-
sourceCompatibility = 11
14+
java {
15+
sourceCompatibility = JavaVersion.VERSION_11
16+
targetCompatibility = JavaVersion.VERSION_11
17+
}
1518

1619
group = 'org.opencadc'
17-
version = '0.4.1'
20+
version = '0.4.2'
1821

1922
description = 'OpenCADC FITS cutout library'
2023
def git_url = 'https://github.com/opencadc/dal'
2124

2225
dependencies {
2326
implementation 'org.opencadc:cadc-dali:[1.2.10,2.0.0)'
24-
implementation 'org.opencadc:cadc-util:[1.6,2.0)'
27+
implementation 'org.opencadc:cadc-util:[1.12.5,2.0)'
2528
implementation 'org.opencadc:cadc-soda-server:[1.2.1,2.0)'
26-
implementation 'org.opencadc:cadc-wcs:[2.1.4,3.0)'
29+
implementation 'org.opencadc:cadc-wcs:[2.2.0,2.4.0)'
2730
implementation 'org.opencadc:jsky:[1.0.0,2.0.0)'
28-
implementation 'gov.nasa.gsfc.heasarc:nom-tam-fits:1.20.0'
31+
implementation 'gov.nasa.gsfc.heasarc:nom-tam-fits:1.22.0'
2932

3033
// Use JUnit test framework
3134
testImplementation 'junit:junit:[4.13,5.0)'
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.opencadc.fits.slice;
2+
3+
import java.util.Arrays;
4+
import nom.tam.fits.header.Standard;
5+
import org.apache.log4j.Logger;
6+
7+
8+
/**
9+
* Fills a flat {@code long[naxis*2]} bounds array: the cut axis uses clipped interval coordinates
10+
* ({@code clippedBounds[0]},{@code clippedBounds[1]} when present); every other axis spans
11+
* {@code [1, NAXISn]} for that axis. {@code naxisPerAxis.length} must equal {@code naxis} (FITS
12+
* NAXIS1..NAXISn sizes in axis order).
13+
*/
14+
public class AxisBoundsFiller {
15+
private static final Logger LOGGER = Logger.getLogger(AxisBoundsFiller.class);
16+
17+
/**
18+
* @param naxis FITS NAXIS (number of axes); array length in elements is {@code 2 * naxis}
19+
* @param clippedBounds pixel bounds on {@code clipAxis} (up to two values), or {@code null} only
20+
* when {@code naxis} is 0
21+
* @param clipAxis 1-based axis index receiving {@code clippedBounds}
22+
* @param naxisPerAxis per-axis NAXISn lengths, length {@code naxis}
23+
*/
24+
static long[] fill(final int naxis, final long[] clippedBounds, final int clipAxis, final int[] naxisPerAxis) {
25+
LOGGER.debug("Filling bounds for naxis=" + naxis + ", clip axis " + clipAxis + ", clipped bounds "
26+
+ Arrays.toString(clippedBounds));
27+
final int flatLen = 2 * naxis;
28+
final long[] bounds = new long[flatLen];
29+
for (int i = 0; i < flatLen; i += 2) {
30+
final int axis = (i + 2) / 2;
31+
if (axis == clipAxis) {
32+
bounds[i] = clippedBounds != null && clippedBounds.length > 0 ? clippedBounds[0] : 1L;
33+
bounds[i + 1] = clippedBounds != null && clippedBounds.length > 1
34+
? clippedBounds[1] : naxisPerAxis[axis - 1];
35+
} else {
36+
bounds[i] = 1L;
37+
bounds[i + 1] = naxisPerAxis[axis - 1];
38+
}
39+
}
40+
LOGGER.debug("Filled bounds: " + Arrays.toString(bounds));
41+
42+
return bounds;
43+
}
44+
45+
static int[] naxisSizes(final FITSHeaderWCSKeywords wcs, final int naxis) {
46+
final int[] sizes = new int[naxis];
47+
for (int a = 1; a <= naxis; a++) {
48+
sizes[a - 1] = wcs.getIntValue(Standard.NAXISn.n(a).key());
49+
}
50+
return sizes;
51+
}
52+
}

cadc-data-ops-fits/src/main/java/org/opencadc/fits/slice/EnergyCutout.java

Lines changed: 149 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
import ca.nrc.cadc.dali.EnergyConverter;
7272
import ca.nrc.cadc.dali.Interval;
7373
import ca.nrc.cadc.wcs.Transform;
74+
import ca.nrc.cadc.wcs.WCSKeywords;
75+
import ca.nrc.cadc.wcs.WCSKeywordsImpl;
7476
import ca.nrc.cadc.wcs.exceptions.NoSuchKeywordException;
7577
import ca.nrc.cadc.wcs.exceptions.WCSLibRuntimeException;
7678
import java.util.Arrays;
@@ -130,14 +132,22 @@ public long[] getBounds(final Interval<Number> bounds) throws NoSuchKeywordExcep
130132
return null;
131133
} else {
132134
final int naxis = spectralWCSKeywords.getIntValue(Standard.NAXIS.key());
133-
final Interval<Double> boundsIntervalPixel = getCutoutPixelInterval(bounds, energyAxis, naxis);
134-
final Interval<Double> nativePixelsInterval =
135-
new Interval<>(0.0D, (double) spectralWCSKeywords.getIntValue(
136-
Standard.NAXISn.n(energyAxis).key()));
135+
final String ctype = spectralWCSKeywords.getStringValue(Standard.CTYPEn.n(energyAxis).key());
136+
final boolean isVelocity = CoordTypeCode.fromCType(ctype).isVelocity();
137+
// Intersect user-requested wavelength (m) with the spectral range covered by the data at pixels 1..N
138+
// so that extended / infinite physical bounds (e.g. -Inf) map to a finite WCS call instead of
139+
// sky2pix endpoints that miss the field of view in pixel space.
140+
final Interval<Number> requestMetres = isVelocity ? bounds : clampWavelengthToSpectralFieldOfView(bounds, energyAxis);
141+
if (requestMetres == null) {
142+
return null;
143+
}
144+
final int nchan = spectralWCSKeywords.getIntValue(Standard.NAXISn.n(energyAxis).key());
145+
final Interval<Double> boundsIntervalPixel = getCutoutPixelInterval(requestMetres, energyAxis, naxis);
146+
// FITS pixel indices 1..N; clip() in FITSCutout is 1-based to len inclusive.
147+
final Interval<Double> nativePixelsInterval = new Interval<>(1.0D, (double) nchan);
137148
final Interval<Double> intersectionPixels = getOverlap(nativePixelsInterval, boundsIntervalPixel);
138149

139150
if (intersectionPixels == null) {
140-
LOGGER.warn("No overlap.");
141151
return null;
142152
} else {
143153
final double low = intersectionPixels.getLower();
@@ -149,39 +159,147 @@ public long[] getBounds(final Interval<Number> bounds) throws NoSuchKeywordExcep
149159
clip(maxSpectralLength, (long) Math.floor(Math.min(low, up) + 0.5D),
150160
(long) Math.ceil(Math.max(low, up) - 0.5D));
151161

152-
final long[] entireBounds = clippedSpectralBounds == null ? null : new long[naxis * 2];
153-
154-
if (entireBounds != null) {
155-
for (int i = 0; i < entireBounds.length; i += 2) {
156-
final int axis = (i + 2) / 2;
157-
if (axis == energyAxis) {
158-
entireBounds[i] = clippedSpectralBounds[0];
159-
entireBounds[i + 1] = clippedSpectralBounds[1];
160-
} else {
161-
entireBounds[i] = 1L;
162-
entireBounds[i + 1] = (long) this.fitsHeaderWCSKeywords.getDoubleValue(
163-
Standard.NAXISn.n(axis).key());
164-
}
165-
}
166-
}
167-
168-
return entireBounds;
162+
final int fillNaxis = clippedSpectralBounds == null ? 0 : naxis;
163+
return AxisBoundsFiller.fill(fillNaxis, clippedSpectralBounds, energyAxis,
164+
AxisBoundsFiller.naxisSizes(spectralWCSKeywords, naxis));
169165
}
170166
}
171167
}
172168

173-
private Interval<Double> getOverlap(final Interval<Double> headerWCSInterval, final Interval<Double> cutoutBounds) {
174-
LOGGER.debug("Checking overlap between header pixels ("
175-
+ headerWCSInterval.getLower() + ", " + headerWCSInterval.getUpper()
176-
+ ") and requested bounds pixels ("
177-
+ cutoutBounds.getLower() + ", " + cutoutBounds.getUpper() + ")");
178-
if (headerWCSInterval.getLower() > cutoutBounds.getUpper()
179-
|| headerWCSInterval.getUpper() < cutoutBounds.getLower()) {
169+
private Interval<Double> getOverlap(final Interval<Double> a, final Interval<Double> b) {
170+
final double lo = Math.max(a.getLower(), b.getLower());
171+
final double hi = Math.min(a.getUpper(), b.getUpper());
172+
LOGGER.debug("Pixel interval intersection (" + a.getLower() + ", " + a.getUpper() + ") with ("
173+
+ b.getLower() + ", " + b.getUpper() + ") -> (" + lo + ", " + hi + ")");
174+
if (lo > hi) {
180175
return null;
176+
}
177+
return new Interval<>(lo, hi);
178+
}
179+
180+
/**
181+
* Wavelength in metres (barycentric) for channels 1 and nchan, using a linear WCS in the native
182+
* spectral unit (CUNIT) at the reference pixel (same approximation as a simple 1D grid).
183+
* @see <a href="https://github.com/opencadc/caom2/blob/main/caom2-compute/src/main/java/ca/nrc/cadc/caom2/compute/EnergyUtil.java#L496">caom2-compute source (toInterval)</a>
184+
* @return The ordered pair [min, max] in metres.
185+
*/
186+
static Interval<Number> spectralWavelengthMetresAtBandEdges(final int energyAxis, final WCSKeywords wcsKeywords)
187+
throws NoSuchKeywordException, WCSLibRuntimeException {
188+
// wcslib translate/pix2sky require NAXIS to match the coordinate array length; use a 1D
189+
// spectral WCS as in caom2-compute WCSWrapper(SpectralWCS, 1).
190+
final WCSKeywords spectralWcs = EnergyCutout.extractSpectralAxis(wcsKeywords, energyAxis);
191+
Transform trans = new Transform(spectralWcs);
192+
final String ctype = spectralWcs.getStringValue(Standard.CTYPEn.n(1).key());
193+
final WCSKeywords kw;
194+
if (!ctype.startsWith(EnergyConverter.CORE_CTYPE)) {
195+
LOGGER.debug("toInterval: transform from " + ctype + " to " + EnergyConverter.CORE_CTYPE + "-???");
196+
kw = trans.translate(EnergyConverter.CORE_CTYPE + "-???"); // any linearization algorithm
197+
trans = new Transform(kw);
181198
} else {
182-
return new Interval<>(Math.max(headerWCSInterval.getLower(), cutoutBounds.getLower()),
183-
Math.min(headerWCSInterval.getUpper(), cutoutBounds.getUpper()));
199+
kw = spectralWcs;
200+
}
201+
double naxis = kw.getDoubleValue("NAXIS1");
202+
double p1 = 0.5;
203+
double p2 = naxis + 0.5;
204+
Transform.Result start = trans.pix2sky(new double[] {p1});
205+
Transform.Result end = trans.pix2sky(new double[] {p2});
206+
207+
double a = start.coordinates[0];
208+
double b = end.coordinates[0];
209+
LOGGER.debug("toInterval: wcslib returned " + a + start.units[0] + "," + b + end.units[0]);
210+
211+
final String specsys = kw.getStringValue(CADCExt.SPECSYS.key());
212+
final EnergyConverter energyConverter = new EnergyConverter();
213+
if (!EnergyConverter.CORE_SPECSYS.equals(specsys)) {
214+
a = energyConverter.convertSpecsys(a, specsys);
215+
b = energyConverter.convertSpecsys(b, specsys);
216+
}
217+
218+
// wcslib convert to WAVE-??? but units might be a multiple of EnergyConverter.CORE_UNIT
219+
String cunit = start.units[0]; // assume same as end.units[0]
220+
if (!EnergyConverter.CORE_UNIT.equals(cunit)) {
221+
LOGGER.debug("toInterval: converting " + a + " " + cunit);
222+
a = energyConverter.convert(a, EnergyConverter.CORE_CTYPE, cunit);
223+
LOGGER.debug("toInterval: converting " + b + " " + cunit);
224+
b = energyConverter.convert(b, EnergyConverter.CORE_CTYPE, cunit);
225+
}
226+
227+
return new Interval<>(Math.min(a, b), Math.max(a, b));
228+
}
229+
230+
/**
231+
* Build a 1-axis WCS containing only the spectral axis, mapped to axis 1.
232+
*/
233+
static WCSKeywords extractSpectralAxis(final WCSKeywords wcsKeywords, final int energyAxis) {
234+
final WCSKeywordsImpl kw = new WCSKeywordsImpl();
235+
kw.put("NAXIS", 1);
236+
kw.put("NAXIS1", wcsKeywords.getIntValue(Standard.NAXISn.n(energyAxis).key()));
237+
kw.put("CTYPE1", wcsKeywords.getStringValue(Standard.CTYPEn.n(energyAxis).key()));
238+
kw.put("CRPIX1", wcsKeywords.getDoubleValue(Standard.CRPIXn.n(energyAxis).key()));
239+
kw.put("CRVAL1", wcsKeywords.getDoubleValue(Standard.CRVALn.n(energyAxis).key()));
240+
241+
final String cunitKey = CADCExt.CUNITn.n(energyAxis).key();
242+
if (wcsKeywords.containsKey(cunitKey)) {
243+
kw.put("CUNIT1", wcsKeywords.getStringValue(cunitKey));
244+
}
245+
246+
final String cdeltKey = Standard.CDELTn.n(energyAxis).key();
247+
if (wcsKeywords.containsKey(cdeltKey)) {
248+
kw.put("CDELT1", wcsKeywords.getDoubleValue(cdeltKey));
249+
}
250+
251+
EnergyCutout.copyOptionalStringKeyword(wcsKeywords, kw, CADCExt.SPECSYS.key());
252+
EnergyCutout.copyOptionalDoubleKeyword(wcsKeywords, kw, CADCExt.RESTFRQ.key());
253+
EnergyCutout.copyOptionalDoubleKeyword(wcsKeywords, kw, CADCExt.RESTWAV.key());
254+
EnergyCutout.copyOptionalDoubleKeyword(wcsKeywords, kw, "RESTFREQ");
255+
EnergyCutout.copyOptionalIntKeyword(wcsKeywords, kw, "VELREF");
256+
return kw;
257+
}
258+
259+
private static void copyOptionalStringKeyword(final WCSKeywords source, final WCSKeywordsImpl target,
260+
final String key) {
261+
if (source.containsKey(key)) {
262+
target.put(key, source.getStringValue(key));
263+
}
264+
}
265+
266+
private static void copyOptionalDoubleKeyword(final WCSKeywords source, final WCSKeywordsImpl target,
267+
final String key) {
268+
if (source.containsKey(key)) {
269+
target.put(key, source.getDoubleValue(key));
270+
}
271+
}
272+
273+
private static void copyOptionalIntKeyword(final WCSKeywords source, final WCSKeywordsImpl target,
274+
final String key) {
275+
if (source.containsKey(key)) {
276+
target.put(key, source.getIntValue(key));
277+
}
278+
}
279+
280+
/**
281+
* Intersect the requested barycentric wavelength range (m) with the [min,max] in metres of the
282+
* spectral band on the data (linear in native unit at pixels 1 and nchan). Handles ±Infinity on
283+
* the request via {@code max}/{@code min} with the band; returns null if there is no intersection.
284+
*/
285+
private Interval<Number> clampWavelengthToSpectralFieldOfView(final Interval<Number> bounds, final int energyAxis)
286+
throws NoSuchKeywordException, WCSLibRuntimeException {
287+
final double wmin = bounds.getLower().doubleValue();
288+
final double wmax = bounds.getUpper().doubleValue();
289+
final double rlo = Math.min(wmin, wmax);
290+
final double rhi = Math.max(wmin, wmax);
291+
final Interval<Number> m = EnergyCutout.spectralWavelengthMetresAtBandEdges(energyAxis, this.fitsHeaderWCSKeywords);
292+
final double mmin = m.getLower().doubleValue();
293+
final double mmax = m.getUpper().doubleValue();
294+
// max(rlo, mmin) lets -Inf select the in-band start; min(rhi, mmax) clips a request that runs past the band.
295+
final double cLo = Math.max(rlo, mmin);
296+
final double cHi = Math.min(rhi, mmax);
297+
LOGGER.debug("Spectral (m) request [" + rlo + ", " + rhi + "] with header band ~[" + mmin + ", " + mmax
298+
+ "] -> [" + cLo + ", " + cHi + "]");
299+
if (cLo > cHi) {
300+
return null;
184301
}
302+
return new Interval<>(cLo, cHi);
185303
}
186304

187305
/**

0 commit comments

Comments
 (0)