Skip to content

Feature request: Handle WCS projections for plotting astronomical images. #606

Open

Description

@astrogewgaw

Currently, it seems that plotting with WCS does not work in UltraPlot. Here is a minimal working example:

import ultraplot as uplt
from astropy.coordinates import SkyCoord

coords = SkyCoord("04h30m49.1s +64d50m53.0s")

fig = uplt.figure()
ax = fig.add_axes([0.1,0.1,0.85,0.85], projection=mosaic.wcs)
ax.plot(
    coords.ra,
    coords.dec,
    ".",
    transform=ax.get_transform("icrs"),
    mec="b",
    mfc="none",
)
uplt.show()

This raises the following error:

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[13], line 11
      5 fig = uplt.figure()
      6 ax = fig.add_axes([0.1,0.1,0.85,0.85], projection=mosaic.wcs)
      7 ax.plot(
      8     coords.ra,
      9     coords.dec,
     10     ".",
---> 11     transform=ax.get_transform("icrs"),
     12     mec="b",
     13     mfc="none",
     14 )
     15 uplt.show()

TypeError: Artist.get_transform() takes 1 positional argument but 2 were given

Using Matplotlib, this works:

import matplotlib.pyplot as plt
from astropy.coordinates import SkyCoord

coords = SkyCoord("04h30m49.1s +64d50m53.0s")

fig = plt.figure(1)
ax = fig.add_axes([0.1,0.1,0.85,0.85], projection=mosaic.wcs)
ax.plot(
    beam.ra,
    beam.dec,
    ".",
    transform=ax.get_transform("icrs"),
    mec="b",
    mfc="none",
)
plt.show()

I am assuming that UltraPlot cannot handle WCS projections, hence this issue is framed as a feature request. As far as I am aware, UltraPlot has a lot of features focused on plotting geographical data. It would be great if features related to plotting astronomical data and images could also be added, especially since many of the plots we make are quite similar to ones made for geographical data.

Activity

  1. cvanelteren commented on Mar 7, 2026

    @cvanelteren
    Collaborator

    I think the projection is handled properly by our backend; should be easy fix.

  2. cvanelteren commented on Mar 7, 2026

    @cvanelteren
    Collaborator

    I am suspecting that the transform is not properly registered by the package -- will have a look. The error indicates that it is assuming the default transform object (which does not take an argument) but the astropy backends do take an argument. The registration should fix this normally -- but it is not properly registering at ultraplot's level.

  3. added a commit that references this issue on Mar 7, 2026
    f677cb3
  4. cvanelteren commented on Mar 7, 2026

    @cvanelteren
    Collaborator

    Can you checkout #607 @astrogewgaw -- I don't have access to the image you have above (code is not complete).

  5. astrogewgaw commented on Mar 9, 2026

    @astrogewgaw
    Author

    Hi @cvanelteren! Thanks for noticing, I completely missed that. Here is a more complete example as a Jupyter notebook, which should run standalone: mwe.ipynb. Let me know if there are any issues accessing it. I basically create the WCS myself in this case, but I think there should be a similar issue if one tries this with a FITS image.

  6. cvanelteren commented on Mar 9, 2026

    @cvanelteren
    Collaborator

    Easier would be to provide a minimal example to use. But the fix on #607 should address the problem.

    The first plot shows this:

    Image

    the second looks fine, not sure if that is normal

  7. cvanelteren commented on Mar 9, 2026

    @cvanelteren
    Collaborator

    Ok I made a small test code; ther is something odd happening with our external axes wrapper. Would need to spend a bit more time on this.

  8. cvanelteren commented on Mar 9, 2026

    @cvanelteren
    Collaborator

    Ok had some time to look at this. The issue is that UltraPlot has a tight control over custom axes to control how it looks like to the reader. The implementation is provided for 2D axes, 3D axes, and GeoAxes, with some recent addition of hosting external axes (e.g. mpltern); but the support is a bit yanky. Namely, for mpl tern we are hosting those axes as a subplot within a native UltraPlot axis and just hiding its axes. Here, a feedback loop is created because astropy handles its layout differerently.
    When implementing the external container class for mpl tern I was under the impression that not that many axes would exist outside geo and ternary plots -- but astropy is proving me wrong. The best integration would be to write a custom adapter for Astropy to play nice with UltraPlot -- which would cost time and effort I don't have at this moment.

    I can probably jank it in, but not sure if that is the way to go and it is generally a problem for another package that will provide another layout mechanism. The reason why matplotlib does not have this issue it that it does not have methods like

    • panel creation
    • sharing logic
    • abc/title management
    • guide/colorbar/legend integration

    Locally I have a fix that can render the astropy axes, but it is not very pretty and I cannot test it to the full extend as this is outside my field of expertise.

    What would be necessary (in case somebody wants to work on this) is:

    The most plausible shape is a dedicated AstroAxes class modeled after ThreeAxes and GeoAxes:966, registered alongside
    the built-in projections in axes/init.py.

    What would be needed:

    • Define a native axes class.
      The first thing to test is a cooperative MRO class like AstroAxes(shared._SharedAxes, plot.PlotAxes, WCSAxes).
      This is the hard part, because PlotAxes:1885 and Astropy WCSAxes both sit on top of Matplotlib Axes.
      If that MRO is unstable, the fallback is a thinner class more like ThreeAxes that does not try to inherit all
      plotting overrides in v1.

    • Register it as a real UltraPlot projection.
      Add it to axes/init.py with _name / aliases so UltraPlot treats it as a first-class axes, not an external child.

    • Teach figure creation to route WCS objects to it.
      Update Figure._parse_proj():1398 so projection=wcs resolves to ultraplot_astropy instead of falling through to the
      external container path.

    • Decide the supported Astropy scope up front.
      I would keep v1 narrow:

      • 2D celestial WCS only
      • astropy.wcs.WCS and maybe high-level WCS API objects
      • no spectral cubes / sliced N-D support beyond what Astropy already exposes cleanly
    • Implement Astro-specific init and state.
      The class would need to accept and persist Astropy-specific inputs such as:

      • wcs
      • slices
      • coord_meta
      • frame_class
      • maybe transform

      This needs to line up with WCSAxes.init(..., wcs=..., slices=..., ...), not with the current generic container
      constructor.

    • Build an UltraPlot-aware format() bridge.
      This is where most of the work is.
      A native Astropy axes cannot just reuse Cartesian formatting blindly because WCS axes semantics live in coords[...],
      not just xaxis / yaxis.
      You would need to decide how UltraPlot keywords map to:

      • grid visibility
      • RA/Dec labels
      • tick visibility and placement
      • tick formatters/locators
      • frame styling
      • titles and abc labels
    • Define transform semantics for plotting commands.
      The class should make ax.get_transform("icrs") work natively, but also decide what UltraPlot plotting defaults mean:

      • should imshow() stay pixel-native by default?
      • should plot()/scatter() assume pixel coordinates unless transform= is passed?
      • do we want any world-coordinate convenience defaults like GeoAxes does?
    • Revisit sharing, panels, and guides.
      UltraPlot’s layout/panel/sharing logic expects native UltraPlot axes, which is the main reason to do this work at
      all.
      But some features may need to be limited initially:

      • disable or constrain sharex / sharey
      • verify outer panels/colorbars/legends
      • verify inset axes behavior
      • verify repeated draw/savefig/resize cycles
    • Add a serious regression matrix.
      At minimum:

      • repeated canvas.draw()
      • savefig() before and after redraw
      • multi-subplot layouts
      • titles/abc labels
      • colorbars and inset axes
      • coordinate overlays and non-default frames
  9. astrogewgaw commented on Mar 12, 2026

    @astrogewgaw
    Author

    @cvanelteren As requested, here is a minimal working example. Note that this is the same code as the Jupyter notebook I shared earlier, and I think it should be stand alone. In case it isn't, do let me know:

    import ultraplot as uplt
    import astropy.units as u
    from astropy.wcs import WCS
    import matplotlib.pyplot as plt
    from astropy.coordinates import SkyCoord
    from astropy.visualization.wcsaxes import SphericalCircle
    
    coords = SkyCoord("04h30m49.1s +64d50m53.0s")
    coords
    
    npix = 100
    radius = (246.68296780422142 * u.arcsecond).to(u.deg)
    dra = ddec = (2.2 * radius / npix).value
    dra = -dra
    wcs = WCS(
        {
            "CTYPE1": "RA---SIN",
            "CTYPE2": "DEC--SIN",
            "CUNIT1": "deg",
            "CUNIT2": "deg",
            "CDELT1": dra,
            "CDELT2": ddec,
            "NAXIS1": npix,
            "NAXIS2": npix,
            "CRPIX1": npix / 2,
            "CRPIX2": npix / 2,
            "CRVAL1": coords.ra.deg,
            "CRVAL2": coords.dec.deg,
            "PC1_1 ": 1.0,
            "PC1_2 ": 0.0,
            "PC2_1 ": 0.0,
            "PC2_2 ": 1.0,
        }
    )
    
    fig = uplt.figure()
    ax = fig.add_axes([0.1,0.1,0.85,0.85], projection=wcs)
    ax.plot(
        coords.ra,
        coords.dec,
        ".",
        transform=ax.get_transform("icrs"),
        mec="b",
        mfc="none",
    )
    circle = SphericalCircle(
        coords,
        radius,
        edgecolor="r",
        facecolor="none",
        transform=ax.get_transform("icrs"),
    )
    ax.add_patch(circle)
    uplt.show()
    
    fig = plt.figure(1)
    ax = fig.add_axes([0.1,0.1,0.85,0.85], projection=wcs)
    ax.plot(
        coords.ra,
        coords.dec,
        ".",
        transform=ax.get_transform("icrs"),
        mec="b",
        mfc="none",
    )
    circle=SphericalCircle(
        coords,
        radius,
        edgecolor="r",
        facecolor="none",
        transform=ax.get_transform("icrs"),
    )
    ax.add_patch(circle)
    plt.show()
  10. cvanelteren commented on Jul 14, 2026

    @cvanelteren
    Collaborator

    @astrogewgaw I am going to pick this up again, are you around for review by any chance?

  11. astrogewgaw commented on Jul 16, 2026

    @astrogewgaw
    Author

    @cvanelteren Sure, I can help with some of the testing. I will see if I can test it with my own use cases, as well as some FITS images. Also, maybe @beckermr can also have a look at this if he has some time? He should be more familiar with WCS projections, and could test this more rigorously than I. I don't deal with that many astronomical images in my line of work, I am more of a time-domain astronomy guy.

  12. cvanelteren commented on Jul 16, 2026

    @cvanelteren
    Collaborator

    On #607 astropy support is added. My main concern is that I (i) forces a bundle on the conda install and (ii) I will not daily drive it so I cannot catch bugs early is they arrive.

  13. added 3 commits that reference this issue on Aug 13, 2026
    22f8e70
    60dfc90
    e8f2452
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions