Skip to content

Commit b48105f

Browse files
committed
Add ZPN projection support and fix wide-field WCS fitting
- Add ZPN+SIP fitting in astrometry_wcs: fit_zpn_wcs_from_points for radial PV terms, _fit_zpn_sip for alternating PV/SIP refinement - Resolve CD/PV degeneracy by letting PV2_1 float during optimization then normalizing to PV2_1=1 via _normalize_zpn_pv1 (3x faster convergence than fixing PV2_1) - Add SIP coordinate normalization to prevent ill-conditioning for SIP>=4 on wide-field images (x_scale parameter for least_squares) - Add convergence check in ZPN+SIP alternation (90th percentile, early stop when relative change < 0.5%) - Add convert_wcs_projection utility for switching between projections - Wire ZPN support through refine_wcs_quadhash (projection='ZPN') - Document ZPN projection for wide-field images in astrometry.rst
1 parent e4615a2 commit b48105f

4 files changed

Lines changed: 394 additions & 21 deletions

File tree

doc/astrometry.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ Key features:
107107
- **Robust pattern matching** - uses geometric hashing of star quadrilaterals for reliable matching even with large initial WCS errors
108108
- **High accuracy** - typically 2-7x more accurate than SCAMP, with sub-arcsecond residuals
109109
- **SIP distortion fitting** - supports polynomial distortion orders 1-3
110-
- **Projection-independent** - works with any WCS projection (TAN, ZEA, SIN, etc.), not just TAN
110+
- **Projection-independent** - works with any WCS projection (TAN, ZEA, SIN, STG, ARC, etc.), not just TAN
111+
- **ZPN projection for wide fields** - for images with FoV > 5°, the zenithal polynomial (ZPN) projection with PV radial terms plus optional SIP corrections gives lower residuals than TAN-SIP
111112
- **Iterative refinement** - affine re-matching and progressive sigma-clipping for robust outlier rejection
112113

113114
.. code-block:: python
@@ -121,6 +122,11 @@ Key features:
121122
wcs = refine_wcs_quadhash(obj, cat, wcs=wcs, sr=10/3600,
122123
order=2, sn=5, verbose=True)
123124
125+
# Wide-field images (FoV > 5 degrees): use ZPN projection
126+
wcs = refine_wcs_quadhash(obj, cat, wcs=wcs, sr=30/3600,
127+
order=2, projection='ZPN', pv_deg=5,
128+
sn=5, verbose=True)
129+
124130
if wcs is not None:
125131
# Update WCS info in the header
126132
astrometry.clear_wcs(header, remove_comments=True,

stdpipe/astrometry_quad.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -998,7 +998,14 @@ def _try_insert(score: float, R: np.ndarray, t: np.ndarray, s: float):
998998

999999
return pairs, top_hyp, best
10001000

1001-
def refine(self, obj: Table, cat: Table, wcs_init: WCS) -> Tuple[WCS, Table, dict]:
1001+
def refine(
1002+
self,
1003+
obj: Table,
1004+
cat: Table,
1005+
wcs_init: WCS,
1006+
fit_projection: Optional[WCS] = None,
1007+
pv_deg: int = 5,
1008+
) -> Tuple[WCS, Table, dict]:
10021009
if not all(k in obj.colnames for k in ("x", "y")):
10031010
raise ValueError("obj must contain 'x' and 'y'")
10041011
if not all(k in cat.colnames for k in ("ra", "dec", "mag")):
@@ -1212,6 +1219,10 @@ def refine(self, obj: Table, cat: Table, wcs_init: WCS) -> Tuple[WCS, Table, dic
12121219
refined_wcs = None
12131220
keep = np.ones(xy.shape[1], dtype=bool)
12141221

1222+
# Projection template for WCS fitting (may differ from wcs_init
1223+
# which is used only for the matching phase)
1224+
wcs_fit_proj = fit_projection if fit_projection is not None else wcs_init
1225+
12151226
# Progressive clipping: interpolate from start to end sigma
12161227
clip_start = self.cfg.refine_clip_sigma_start
12171228
clip_end = self.cfg.refine_clip_sigma
@@ -1232,8 +1243,9 @@ def refine(self, obj: Table, cat: Table, wcs_init: WCS) -> Tuple[WCS, Table, dic
12321243
refined_wcs = fit_wcs_from_points(
12331244
xy_use,
12341245
sky_use,
1235-
projection=wcs_init,
1246+
projection=wcs_fit_proj,
12361247
sip_degree=int(self.cfg.sip_degree),
1248+
pv_deg=pv_deg,
12371249
)
12381250

12391251
# Residuals via spherical distance (projection-independent)
@@ -1315,6 +1327,8 @@ def refine_wcs_quadhash(
13151327
header=None,
13161328
sr: float = 10 / 3600,
13171329
order: int = 2,
1330+
projection: str = None,
1331+
pv_deg: int = 5,
13181332
cat_col_ra: str = 'RAJ2000',
13191333
cat_col_dec: str = 'DEJ2000',
13201334
cat_col_mag: str = 'rmag',
@@ -1357,7 +1371,30 @@ def refine_wcs_quadhash(
13571371
sr : float, optional
13581372
Matching radius in degrees for stage 2 matching.
13591373
order : int, optional
1360-
SIP polynomial order for the distortion solution (1–3).
1374+
SIP polynomial order for the distortion solution (0–5).
1375+
For TAN projection, controls the SIP polynomial degree.
1376+
For ZPN projection, controls additional SIP corrections on top
1377+
of the PV radial model (0 means ZPN-only, 2 is recommended for
1378+
wide-field images). Ignored for other projections.
1379+
projection : str or None, optional
1380+
Target projection type for the output WCS. If ``None`` (default),
1381+
uses the same projection as the input WCS. Supported values:
1382+
1383+
- ``'TAN'`` — gnomonic (TAN) with SIP distortion polynomials
1384+
- ``'ZPN'`` — zenithal polynomial with PV radial terms (+ optional
1385+
SIP for non-radial corrections). Best for wide-field images
1386+
(FoV > 5°) as ZPN naturally handles radial distortion that
1387+
TAN-SIP struggles with.
1388+
- Any other FITS WCS projection code supported by astropy
1389+
(``'STG'``, ``'ARC'``, ``'ZEA'``, ``'SIN'``, etc.) — linear
1390+
WCS fit only (no distortion polynomials).
1391+
1392+
The initial WCS (``wcs``) is always used for the pattern matching
1393+
phase; the projection conversion only affects the final WCS fit.
1394+
pv_deg : int, optional
1395+
ZPN PV polynomial degree (``PV2_0`` … ``PV2_pv_deg``). Only used
1396+
when ``projection='ZPN'``. Default is 5, which is sufficient for
1397+
most optical systems.
13611398
cat_col_ra : str, optional
13621399
Catalogue column name for Right Ascension. Default is ``'RAJ2000'``.
13631400
cat_col_dec : str, optional
@@ -1416,6 +1453,18 @@ def refine_wcs_quadhash(
14161453
... sn=5, # use only S/N > 5 objects
14171454
... verbose=True
14181455
... )
1456+
1457+
For wide-field images (FoV > 5 degrees), ZPN projection with SIP
1458+
corrections typically gives lower systematic residuals at field edges:
1459+
1460+
>>> wcs_refined = refine_wcs_quadhash(
1461+
... obj, cat, wcs_init,
1462+
... sr=30/3600,
1463+
... order=2, # SIP order on top of ZPN radial model
1464+
... projection='ZPN', # zenithal polynomial projection
1465+
... pv_deg=5, # PV radial polynomial degree
1466+
... verbose=True
1467+
... )
14191468
"""
14201469

14211470
# Simple wrapper around print for logging in verbose mode only
@@ -1541,6 +1590,20 @@ def refine_wcs_quadhash(
15411590
log(f" Source counts: n_det={scaled_n_det}, n_ref={scaled_n_ref}")
15421591
log(f" SIP order: {order}")
15431592

1593+
# Build projection template for the WCS fitting phase
1594+
fit_projection = None
1595+
if projection is not None:
1596+
from stdpipe.astrometry_wcs import convert_wcs_projection
1597+
1598+
proj_code = projection.upper().strip()
1599+
fit_projection = convert_wcs_projection(wcs, proj_code, pv_deg=pv_deg)
1600+
log(f" Output projection: {proj_code} (fit CTYPE: {fit_projection.wcs.ctype})")
1601+
1602+
if proj_code == 'ZPN':
1603+
log(f" ZPN PV degree: {pv_deg}")
1604+
elif proj_code != 'TAN' and order > 0:
1605+
log(f" Note: SIP order={order} ignored for {proj_code} projection (linear fit only)")
1606+
15441607
config = AstrometryConfig(
15451608
n_det=scaled_n_det,
15461609
n_ref=scaled_n_ref,
@@ -1575,7 +1638,10 @@ def refine_wcs_quadhash(
15751638

15761639
try:
15771640
solver = QuadHashAstrometry(config=config)
1578-
wcs_refined, match, diagnostics = solver.refine(obj=obj_std, cat=cat_std, wcs_init=wcs)
1641+
wcs_refined, match, diagnostics = solver.refine(
1642+
obj=obj_std, cat=cat_std, wcs_init=wcs,
1643+
fit_projection=fit_projection, pv_deg=pv_deg,
1644+
)
15791645

15801646
log(f"Refinement successful:")
15811647
log(f" Pattern matches: {diagnostics['pattern_inliers_stage2']}")

0 commit comments

Comments
 (0)