1use crate::header::Header;
2use crate::wcs::Projection;
3use crate::wcs::distortion::{Distortion, number};
4use crate::wcs::projection::ProjectionParams;
5use crate::wcs::spherical::Rotation;
6use std::error::Error;
7
8#[derive(Debug, Clone, PartialEq)]
24pub struct Wcs {
25 reference_pixel: (f64, f64),
26 reference_value: (f64, f64),
27 transform: [[f64; 2]; 2],
29 inverse: [[f64; 2]; 2],
31 projection: Projection,
32 rotation: Option<Rotation>,
35 params: ProjectionParams,
36 distortion: Distortion,
37 axes: Vec<Axis>,
39}
40
41#[derive(Debug, Clone, PartialEq)]
44struct Axis {
45 reference_pixel: f64,
46 reference_value: f64,
47 delta: f64,
48 ctype: Option<String>,
49 cunit: Option<String>,
50}
51
52impl Wcs {
53 pub fn from_header(header: &Header) -> Result<Self, Box<dyn Error + Send + Sync>> {
62 let axis = |name: &str, value: Option<f64>, index: usize| {
63 value.ok_or_else(|| {
64 format!(
65 "Header has no {}{} card, so it carries no world coordinate system",
66 name,
67 index + 1
68 )
69 })
70 };
71
72 let reference_pixel = (
73 axis("CRPIX", header.coordinate_reference_pixel(0), 0)?,
74 axis("CRPIX", header.coordinate_reference_pixel(1), 1)?,
75 );
76 let reference_value = (
77 axis("CRVAL", header.coordinate_value_at_pixel(0), 0)?,
78 axis("CRVAL", header.coordinate_value_at_pixel(1), 1)?,
79 );
80
81 let transform = transform_from(header);
82
83 let inverse = invert(transform).ok_or_else(|| {
86 format!(
87 "The header's coordinate transformation matrix {:?} cannot be inverted, so it \
88 describes no usable world coordinate system",
89 transform
90 )
91 })?;
92
93 let ctype = header.coordinate_axis_name(0).map(str::to_string);
95 let projection = match ctype.as_deref() {
96 Some(ctype) => Projection::from_ctype(projection_code(ctype))?,
97 None => Projection::Linear,
98 };
99
100 let rotation = if projection == Projection::Linear {
101 None
102 } else {
103 for index in 0..2 {
104 degrees(header, index)?;
105 }
106
107 Some(Rotation::new(
108 reference_value,
109 projection.fiducial(),
110 number(header, "LONPOLE").or_else(|| number(header, "PV1_3")),
111 number(header, "LATPOLE").or_else(|| number(header, "PV1_4")),
112 )?)
113 };
114
115 let params = ProjectionParams {
116 cea_lambda: number(header, "PV2_1"),
117 };
118
119 Ok(Self {
120 reference_pixel,
121 reference_value,
122 transform,
123 inverse,
124 projection,
125 rotation,
126 params,
127 distortion: Distortion::from_header(header, ctype.as_deref()),
128 axes: axes_of(header),
129 })
130 }
131
132 pub fn transform(&self) -> [[f64; 2]; 2] {
139 self.transform
140 }
141
142 pub fn projection(&self) -> Projection {
144 self.projection
145 }
146
147 pub fn is_celestial(&self) -> bool {
150 self.rotation.is_some()
151 }
152
153 pub fn celestial_pole(&self) -> Option<(f64, f64)> {
160 self.rotation.as_ref().map(Rotation::pole)
161 }
162
163 pub fn axis_count(&self) -> usize {
165 self.axes.len()
166 }
167
168 pub fn axis_type(&self, axis: usize) -> Option<&str> {
170 self.axes.get(axis)?.ctype.as_deref()
171 }
172
173 pub fn axis_unit(&self, axis: usize) -> Option<&str> {
176 self.axes.get(axis)?.cunit.as_deref()
177 }
178
179 pub fn pixel_to_world_axis(&self, axis: usize, pixel: f64) -> Option<f64> {
189 if self.is_celestial() && axis < 2 {
190 return None;
191 }
192
193 let described = self.axes.get(axis)?;
194 let offset = described.delta * (pixel - described.reference_pixel);
195
196 Some(if self.is_logarithmic(axis) {
197 described.reference_value * (offset / described.reference_value).exp()
198 } else {
199 described.reference_value + offset
200 })
201 }
202
203 pub fn world_to_pixel_axis(&self, axis: usize, world: f64) -> Option<f64> {
206 if self.is_celestial() && axis < 2 {
207 return None;
208 }
209
210 let described = self.axes.get(axis)?;
211
212 if described.delta == 0.0 {
213 return None;
214 }
215
216 let offset = if self.is_logarithmic(axis) {
217 described.reference_value * (world / described.reference_value).ln()
218 } else {
219 world - described.reference_value
220 };
221
222 Some(described.reference_pixel + offset / described.delta)
223 }
224
225 fn is_logarithmic(&self, axis: usize) -> bool {
227 self.axes
228 .get(axis)
229 .and_then(|axis| axis.ctype.as_deref())
230 .is_some_and(|ctype| ctype.trim().ends_with("-LOG"))
231 }
232
233 pub fn pixel_to_world(&self, pixel: (f64, f64)) -> (f64, f64) {
237 let intermediate = self.pixel_to_intermediate(pixel);
238
239 let Some(rotation) = &self.rotation else {
240 return (
241 self.reference_value.0 + intermediate.0,
242 self.reference_value.1 + intermediate.1,
243 );
244 };
245
246 let (phi, theta) = self
247 .projection
248 .to_native(intermediate.0, intermediate.1, &self.params);
249
250 rotation.to_celestial(phi, theta)
251 }
252
253 pub fn world_to_pixel(&self, world: (f64, f64)) -> (f64, f64) {
256 let intermediate = match &self.rotation {
257 None => (
258 world.0 - self.reference_value.0,
259 world.1 - self.reference_value.1,
260 ),
261 Some(rotation) => {
262 let (phi, theta) = rotation.to_native(world.0, world.1);
263 self.projection.from_native(phi, theta, &self.params)
264 }
265 };
266
267 self.intermediate_to_pixel(intermediate)
268 }
269
270 pub fn pixel_to_world_indexed(&self, index: (u32, u32)) -> (f64, f64) {
272 self.pixel_to_world((index.0 as f64 + 1.0, index.1 as f64 + 1.0))
273 }
274
275 pub fn world_to_pixel_indexed(
280 &self,
281 world: (f64, f64),
282 width: u32,
283 height: u32,
284 ) -> Option<(u32, u32)> {
285 let (x, y) = self.world_to_pixel(world);
286 let x = (x - 1.0).round();
287 let y = (y - 1.0).round();
288
289 if !x.is_finite() || !y.is_finite() || x < 0.0 || y < 0.0 {
290 return None;
291 }
292
293 let (x, y) = (x as u32, y as u32);
294 (x < width && y < height).then_some((x, y))
295 }
296
297 fn pixel_to_intermediate(&self, pixel: (f64, f64)) -> (f64, f64) {
300 let offset = (
301 pixel.0 - self.reference_pixel.0,
302 pixel.1 - self.reference_pixel.1,
303 );
304
305 let corrected = self.distortion.correct_pixel(offset);
308
309 self.distortion
310 .correct_intermediate(apply(self.transform, corrected))
311 }
312
313 fn intermediate_to_pixel(&self, intermediate: (f64, f64)) -> (f64, f64) {
315 let undistorted = self.distortion.uncorrect_intermediate(intermediate);
316 let offset = self
317 .distortion
318 .uncorrect_pixel(apply(self.inverse, undistorted));
319
320 (
321 self.reference_pixel.0 + offset.0,
322 self.reference_pixel.1 + offset.1,
323 )
324 }
325}
326
327fn projection_code(ctype: &str) -> &str {
333 ctype.trim().strip_suffix("-SIP").unwrap_or(ctype.trim())
334}
335
336fn degrees(header: &Header, index: usize) -> Result<(), Box<dyn Error + Send + Sync>> {
342 let Some(unit) = string(header, &format!("CUNIT{}", index + 1)) else {
343 return Ok(());
345 };
346
347 match unit.trim() {
348 "deg" | "degree" | "degrees" | "" => Ok(()),
349 other => Err(format!(
350 "The celestial axis CUNIT{} is {:?}, and this crate reads celestial coordinates in \
351 degrees",
352 index + 1,
353 other
354 )
355 .into()),
356 }
357}
358
359fn string(header: &Header, key: &str) -> Option<String> {
361 match header.card(key)? {
362 crate::header::Value::String { value, .. } => Some(value),
363 _ => None,
364 }
365}
366
367fn axes_of(header: &Header) -> Vec<Axis> {
369 let count = header.naxis().unwrap_or(0).max(0) as usize;
370
371 (0..count)
372 .map(|index| Axis {
373 reference_pixel: header.coordinate_reference_pixel(index).unwrap_or(0.0),
374 reference_value: header.coordinate_value_at_pixel(index).unwrap_or(0.0),
375 delta: header.coordinate_delta(index).unwrap_or(1.0),
377 ctype: header.coordinate_axis_name(index).map(str::to_string),
378 cunit: string(header, &format!("CUNIT{}", index + 1)),
379 })
380 .collect()
381}
382
383fn transform_from(header: &Header) -> [[f64; 2]; 2] {
398 let element = |row, column| header.coordinate_transform(row, column);
399
400 if (0..2).any(|row| (0..2).any(|column| element(row, column).is_some())) {
403 return [
404 [element(0, 0).unwrap_or(0.0), element(0, 1).unwrap_or(0.0)],
405 [element(1, 0).unwrap_or(0.0), element(1, 1).unwrap_or(0.0)],
406 ];
407 }
408
409 let scale = (
412 header.coordinate_delta(0).unwrap_or(1.0),
413 header.coordinate_delta(1).unwrap_or(1.0),
414 );
415
416 let rotation = |row, column| header.coordinate_rotation_matrix(row, column);
417
418 if (0..2).any(|row| (0..2).any(|column| rotation(row, column).is_some())) {
419 let identity = |row: usize, column: usize| if row == column { 1.0 } else { 0.0 };
421 let element = |row, column| rotation(row, column).unwrap_or_else(|| identity(row, column));
422
423 return [
424 [scale.0 * element(0, 0), scale.0 * element(0, 1)],
425 [scale.1 * element(1, 0), scale.1 * element(1, 1)],
426 ];
427 }
428
429 let angle = header
432 .coordinate_rotation(1)
433 .or_else(|| header.coordinate_rotation(0))
434 .unwrap_or(0.0);
435 let (sin, cos) = angle.to_radians().sin_cos();
436
437 [
441 [scale.0 * cos, -scale.1 * sin],
442 [scale.0 * sin, scale.1 * cos],
443 ]
444}
445
446fn apply(matrix: [[f64; 2]; 2], offset: (f64, f64)) -> (f64, f64) {
448 (
449 matrix[0][0] * offset.0 + matrix[0][1] * offset.1,
450 matrix[1][0] * offset.0 + matrix[1][1] * offset.1,
451 )
452}
453
454fn invert(matrix: [[f64; 2]; 2]) -> Option<[[f64; 2]; 2]> {
456 let determinant = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
457
458 if determinant == 0.0 || !determinant.is_finite() {
459 return None;
460 }
461
462 Some([
463 [matrix[1][1] / determinant, -matrix[0][1] / determinant],
464 [-matrix[1][0] / determinant, matrix[0][0] / determinant],
465 ])
466}
467
468#[cfg(test)]
469mod tests {
470 use super::{Wcs, invert};
471 use crate::header::Header;
472 use crate::wcs::spherical::normalise_longitude;
473
474 fn header(projection: &str, rotation: f64) -> Header {
477 let mut header = Header::default();
478
479 header.set_card("NAXIS", 2_i64).unwrap();
480 header.set_card("CRPIX1", 100.5).unwrap();
481 header.set_card("CRPIX2", 200.5).unwrap();
482 header.set_card("CRVAL1", 150.0).unwrap();
483 header.set_card("CRVAL2", 40.0).unwrap();
484 header.set_card("CDELT1", -0.001).unwrap();
485 header.set_card("CDELT2", 0.001).unwrap();
486 header.set_card("CROTA2", rotation).unwrap();
487 header
488 .set_card("CTYPE1", format!("RA---{projection}"))
489 .unwrap();
490 header
491 .set_card("CTYPE2", format!("DEC--{projection}"))
492 .unwrap();
493
494 header
495 }
496
497 fn wcs(projection: &str, rotation: f64) -> Wcs {
498 Wcs::from_header(&header(projection, rotation)).expect("a complete WCS header")
499 }
500
501 fn assert_close(actual: (f64, f64), expected: (f64, f64)) {
502 assert!(
503 (actual.0 - expected.0).abs() < 1e-9 && (actual.1 - expected.1).abs() < 1e-9,
504 "expected {expected:?}, got {actual:?}"
505 );
506 }
507
508 #[test]
509 fn the_reference_pixel_sits_at_the_reference_value() {
510 for projection in ["TAN", "SIN", "ARC", "STG", "ZEA", "CAR", "AIT", "MOL"] {
511 let wcs = wcs(projection, 0.0);
512 assert_close(wcs.pixel_to_world((100.5, 200.5)), (150.0, 40.0));
513 }
514 }
515
516 #[test]
517 fn pixel_and_world_round_trip() {
518 for projection in [
519 "TAN", "SIN", "ARC", "STG", "ZEA", "CAR", "MER", "AIT", "MOL",
520 ] {
521 for rotation in [0.0, 30.0, -12.5] {
522 let wcs = wcs(projection, rotation);
523
524 for pixel in [(1.0, 1.0), (100.5, 200.5), (512.0, 480.0)] {
525 let world = wcs.pixel_to_world(pixel);
526 assert!(
527 world.0.is_finite(),
528 "{projection} put pixel {pixel:?} nowhere"
529 );
530
531 let back = wcs.world_to_pixel(world);
535 assert!(
536 (back.0 - pixel.0).abs() < 1e-6 && (back.1 - pixel.1).abs() < 1e-6,
537 "{projection} took {pixel:?} to {world:?} and back to {back:?}"
538 );
539 }
540 }
541 }
542 }
543
544 #[test]
545 fn a_linear_axis_is_a_plain_offset_from_the_reference() {
546 let mut header = header("TAN", 0.0);
547 header.set_card("CTYPE1", "LINEAR").unwrap();
548 header.set_card("CTYPE2", "LINEAR").unwrap();
549
550 let wcs = Wcs::from_header(&header).unwrap();
551
552 assert_close(wcs.pixel_to_world((110.5, 200.5)), (149.99, 40.0));
554 assert!(!wcs.is_celestial());
555 }
556
557 #[test]
558 fn a_gnomonic_axis_is_not_a_plain_offset() {
559 let mut linear = header("TAN", 0.0);
562 linear.set_card("CTYPE1", "LINEAR").unwrap();
563 linear.set_card("CTYPE2", "LINEAR").unwrap();
564
565 let linear = Wcs::from_header(&linear)
566 .unwrap()
567 .pixel_to_world((1100.5, 200.5));
568 let gnomonic = wcs("TAN", 0.0).pixel_to_world((1100.5, 200.5));
569
570 assert!(
571 (linear.0 - gnomonic.0).abs() > 1e-6,
572 "TAN and linear should disagree a degree from the reference, got {linear:?} and {gnomonic:?}"
573 );
574 }
575
576 #[test]
577 fn north_is_up_and_east_is_left_in_an_unrotated_image() {
578 let wcs = wcs("TAN", 0.0);
579
580 let north = wcs.pixel_to_world((100.5, 300.5));
583 let right = wcs.pixel_to_world((200.5, 200.5));
584
585 assert!(
586 north.1 > 40.0,
587 "north of the reference should be, got {north:?}"
588 );
589 assert!(
590 (north.0 - 150.0).abs() < 1e-9,
591 "straight north keeps its right ascension, got {north:?}"
592 );
593 assert!(
594 right.0 < 150.0,
595 "the right of the frame is west, got {right:?}"
596 );
597 }
598
599 #[test]
600 fn the_projections_agree_close_to_the_reference_point() {
601 let reference = wcs("TAN", 0.0).pixel_to_world((110.5, 210.5));
605
606 for projection in ["SIN", "ARC", "STG", "ZEA"] {
607 let other = wcs(projection, 0.0).pixel_to_world((110.5, 210.5));
608
609 assert!(
610 (other.0 - reference.0).abs() < 1e-6 && (other.1 - reference.1).abs() < 1e-6,
611 "{projection} put the pixel at {other:?}, TAN at {reference:?}"
612 );
613 }
614 }
615
616 fn gnomonic_by_hand(reference: (f64, f64), xi: f64, eta: f64) -> (f64, f64) {
620 let (xi, eta) = (xi.to_radians(), eta.to_radians());
621 let (longitude, latitude) = (reference.0.to_radians(), reference.1.to_radians());
622 let (sin, cos) = latitude.sin_cos();
623
624 let denominator = cos - eta * sin;
625
626 (
627 normalise_longitude((longitude + xi.atan2(denominator)).to_degrees()),
628 ((sin + eta * cos) / (xi * xi + denominator * denominator).sqrt())
629 .atan()
630 .to_degrees(),
631 )
632 }
633
634 #[test]
635 fn the_gnomonic_projection_agrees_with_its_closed_form() {
636 let wcs = wcs("TAN", 0.0);
637
638 for pixel in [(1.0, 1.0), (250.0, 60.0), (900.5, 1000.5)] {
639 let (u, v) = (pixel.0 - 100.5, pixel.1 - 200.5);
641 let expected = gnomonic_by_hand((150.0, 40.0), -0.001 * u, 0.001 * v);
642
643 let actual = wcs.pixel_to_world(pixel);
644
645 assert!(
646 (actual.0 - expected.0).abs() < 1e-10 && (actual.1 - expected.1).abs() < 1e-10,
647 "at {pixel:?} the closed form gives {expected:?} and the projection {actual:?}"
648 );
649 }
650 }
651
652 #[test]
653 fn indexed_helpers_shift_between_pixel_conventions() {
654 let wcs = wcs("TAN", 0.0);
655
656 assert_close(
658 wcs.pixel_to_world_indexed((0, 0)),
659 wcs.pixel_to_world((1.0, 1.0)),
660 );
661
662 let world = wcs.pixel_to_world_indexed((10, 20));
663 assert_eq!(wcs.world_to_pixel_indexed(world, 512, 512), Some((10, 20)));
664 }
665
666 #[test]
667 fn a_coordinate_outside_the_image_has_no_pixel() {
668 let wcs = wcs("TAN", 0.0);
669
670 let world = wcs.pixel_to_world_indexed((400, 400));
671 assert_eq!(wcs.world_to_pixel_indexed(world, 100, 100), None);
672 }
673
674 #[test]
675 fn a_cubes_third_axis_reads_on_its_own() {
676 let mut header = header("TAN", 0.0);
677 header.set_card("NAXIS", 3_i64).unwrap();
678 header.set_card("CTYPE3", "WAVE").unwrap();
679 header.set_card("CUNIT3", "Angstrom").unwrap();
680 header.set_card("CRPIX3", 1.0).unwrap();
681 header.set_card("CRVAL3", 4000.0).unwrap();
682 header.set_card("CDELT3", 1.25).unwrap();
683
684 let wcs = Wcs::from_header(&header).unwrap();
685
686 assert_eq!(wcs.axis_count(), 3);
687 assert_eq!(wcs.axis_type(2), Some("WAVE"));
688 assert_eq!(wcs.axis_unit(2), Some("Angstrom"));
689 assert_eq!(wcs.pixel_to_world_axis(2, 1.0), Some(4000.0));
690 assert_eq!(wcs.pixel_to_world_axis(2, 5.0), Some(4005.0));
691 assert_eq!(wcs.world_to_pixel_axis(2, 4005.0), Some(5.0));
692
693 assert_eq!(wcs.pixel_to_world_axis(0, 1.0), None);
695 assert_eq!(wcs.pixel_to_world_axis(9, 1.0), None);
696 }
697
698 #[test]
699 fn a_logarithmic_axis_grows_geometrically() {
700 let mut header = header("TAN", 0.0);
701 header.set_card("NAXIS", 3_i64).unwrap();
702 header.set_card("CTYPE3", "FREQ-LOG").unwrap();
703 header.set_card("CRPIX3", 1.0).unwrap();
704 header.set_card("CRVAL3", 1000.0).unwrap();
705 header.set_card("CDELT3", 10.0).unwrap();
706
707 let wcs = Wcs::from_header(&header).unwrap();
708
709 let at_reference = wcs.pixel_to_world_axis(2, 1.0).unwrap();
710 let further = wcs.pixel_to_world_axis(2, 11.0).unwrap();
711
712 assert!((at_reference - 1000.0).abs() < 1e-9);
713 assert!((further - 1000.0 * (100.0_f64 / 1000.0).exp()).abs() < 1e-6);
714 assert!((wcs.world_to_pixel_axis(2, further).unwrap() - 11.0).abs() < 1e-9);
715 }
716
717 #[test]
718 fn a_celestial_axis_in_the_wrong_unit_is_refused() {
719 let mut header = header("TAN", 0.0);
720 header.set_card("CUNIT1", "arcsec").unwrap();
721
722 let error = Wcs::from_header(&header).expect_err("arcseconds are not degrees");
723 assert!(error.to_string().contains("arcsec"), "got: {error}");
724 }
725
726 #[test]
727 fn a_sip_header_bends_the_field_and_bends_it_back() {
728 let mut header = header("TAN", 0.0);
729 header.set_card("CTYPE1", "RA---TAN-SIP").unwrap();
730 header.set_card("CTYPE2", "DEC--TAN-SIP").unwrap();
731 header.set_card("A_ORDER", 2_i64).unwrap();
732 header.set_card("A_2_0", 1e-5).unwrap();
733 header.set_card("B_ORDER", 2_i64).unwrap();
734 header.set_card("B_0_2", -2e-5).unwrap();
735
736 let distorted = Wcs::from_header(&header).unwrap();
737 let ideal = wcs("TAN", 0.0);
738
739 let pixel = (600.5, 700.5);
740
741 let with = distorted.pixel_to_world(pixel);
742 let without = ideal.pixel_to_world(pixel);
743
744 assert!(
745 (with.0 - without.0).abs() > 1e-6 || (with.1 - without.1).abs() > 1e-6,
746 "the correction should move the corner of the frame, got {with:?} and {without:?}"
747 );
748
749 let back = distorted.world_to_pixel(with);
750 assert!(
751 (back.0 - pixel.0).abs() < 1e-6 && (back.1 - pixel.1).abs() < 1e-6,
752 "{pixel:?} came back as {back:?}"
753 );
754 }
755
756 #[test]
757 fn a_tpv_header_bends_the_field_and_bends_it_back() {
758 let mut header = header("TAN", 0.0);
759 header.set_card("CTYPE1", "RA---TPV").unwrap();
760 header.set_card("CTYPE2", "DEC--TPV").unwrap();
761 header.set_card("PV1_1", 1.0).unwrap();
762 header.set_card("PV1_4", 0.002).unwrap();
763 header.set_card("PV2_1", 1.0).unwrap();
764
765 let wcs = Wcs::from_header(&header).unwrap();
766 let pixel = (600.5, 700.5);
767
768 let world = wcs.pixel_to_world(pixel);
769 let back = wcs.world_to_pixel(world);
770
771 assert!(
772 (back.0 - pixel.0).abs() < 1e-6 && (back.1 - pixel.1).abs() < 1e-6,
773 "{pixel:?} came back as {back:?}"
774 );
775 }
776
777 #[test]
778 fn an_all_sky_projection_puts_its_pole_a_quarter_turn_from_its_centre() {
779 let mut header = header("AIT", 0.0);
780 header.set_card("CRVAL1", 0.0).unwrap();
781 header.set_card("CRVAL2", 0.0).unwrap();
782
783 let wcs = Wcs::from_header(&header).unwrap();
784
785 let pole = wcs.celestial_pole().expect("a celestial system has a pole");
786 assert!((pole.1 - 90.0).abs() < 1e-9, "got {pole:?}");
787 }
788
789 #[test]
790 fn a_singular_matrix_has_no_inverse() {
791 assert!(invert([[1.0, 2.0], [2.0, 4.0]]).is_none());
794 assert!(invert([[0.0, 0.0], [0.0, 0.0]]).is_none());
795
796 assert!(invert([[1.0, 0.0], [0.0, 1.0]]).is_some());
797 }
798
799 #[test]
800 fn longitudes_wrap_into_a_single_turn() {
801 assert_eq!(normalise_longitude(370.0), 10.0);
802 assert_eq!(normalise_longitude(-10.0), 350.0);
803 assert_eq!(normalise_longitude(180.0), 180.0);
804 }
805}