Skip to content

Commit 5ad33ee

Browse files
authored
Merge pull request #444 from larrybradley/fits-io
Rewrite FITS region I/O
2 parents 5c9fd68 + f651758 commit 5ad33ee

21 files changed

+631
-541
lines changed

CHANGES.rst

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ New Features
3131
- Added the ability to serialize multiple regions in the DS9 file format
3232
that have different coordinate frames. [#436]
3333

34+
- Added the ability to parse FITS region ``rectangle`` and
35+
``rotrectangle`` shapes. [#444]
36+
3437

3538
Bug Fixes
3639
---------
@@ -64,6 +67,12 @@ Bug Fixes
6467
consistently handling or preserving the region coordinate frame
6568
or region parameter units. [#436]
6669

70+
- Fixed handling of FITS shapes that are preceded by an exclamation
71+
mark. [#444]
72+
73+
- Fixed a bug where written FITS region files could not be read back in.
74+
[#444]
75+
6776

6877
API Changes
6978
-----------
@@ -104,6 +113,10 @@ API Changes
104113
``matplotlib.artist.Artist`` object, which can be used in plot legends.
105114
[#441]
106115

116+
- FITS region files are now always parsed and serialized as
117+
``PixelRegion`` objects. They can be converted to ``SkyRegion``
118+
objects using a WCS object. [#444]
119+
107120

108121
0.5 (2021-07-20)
109122
================
@@ -168,7 +181,7 @@ Bug Fixes
168181
space after the region name. [#271]
169182

170183
- Fixed an issue where the CRTF file parser was too restrictive about
171-
requiring the last and first poly coordinates to be the same.
184+
requiring the last and first polynomial coordinates to be the same.
172185
[#359, #362]
173186

174187
- Fixed a bug where an ``EllipsePixelRegion`` with zero height and/or

docs/fits_io.rst

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
2+
FITS Region File Format Limitations
3+
===================================
4+
5+
Region Shapes
6+
-------------
7+
8+
* FITS regions are specified in pixel units, thus a `~regions.SkyRegion`
9+
cannot be serialized to the FITS region format. Regions are always
10+
parsed and serialized as `~regions.PixelRegion` objects when using the
11+
FITS region format.
12+
13+
* When reading a ``elliptannulus``, the first ``ROTANG`` will be used
14+
for both the inner and outer ellipse. The second ``ROTANG`` will be
15+
ignored. In other words, you cannot have an elliptical annulus where the
16+
inner and outer ellipse have different rotation angles.
17+
18+
* Shapes where the value in the SHAPE column is preceded by an exclamation
19+
mark (e.g., ``!circle``) will be read in as a `~regions.PixelRegion`
20+
object and have ``include = 0`` in their ``meta`` dictionary. When
21+
such objects are serialized, their shape will be prepended by ``!``.
22+
23+
* The FITS ``box``, ``rotbox``, ``rectangle``, and ``rotrectangle``
24+
shapes will all be parsed as a `~regions.RectanglePixelRegion`. In
25+
turn, `~regions.RectanglePixelRegion` is always serialized as a
26+
``rotbox`` shape.
27+
28+
* The following `~regions.PixelRegion` classes do not have corresponding
29+
FITS shapes and therefore are not supported (a warning will be raised):
30+
31+
* `~regions.RectangleAnnulusPixelRegion`
32+
* `~regions.LinePixelRegion`
33+
* `~regions.TextPixelRegion`
34+
* `~regions.CompoundPixelRegion`
35+
36+
* FITS regions are always parsed and serialized into separate regions.
37+
Shapes that have the ``COMPONENT`` column will have that value
38+
stored in the `~regions.PixelRegion` ``meta`` dictionary with the
39+
``component`` key. Such regions will include the ``COMPONENT`` column
40+
when serialized.
41+
42+
* FITS parsing and serialization use only the ``include`` and
43+
``component`` metadata and no visual metadata.
44+
45+
46+
Coordinate Frames
47+
-----------------
48+
49+
* `~regions.Region` objects represent abstract shapes that are not
50+
tied to any particular image or WCS transformation. Therefore, any
51+
WCS information in the FITS region file header will not be read.
52+
Regions are always parsed and serialized as `~regions.PixelRegion`
53+
objects when using the FITS region format. However, if desired you
54+
can use the WCS information in the FITS region file to convert a
55+
`~regions.PixelRegion` object to a `~regions.SkyRegion` object, e.g.,:
56+
57+
.. doctest-skip::
58+
59+
>>> from astropy.io import fits
60+
>>> from astropy.wcs import WCS
61+
>>> header = fits.getheader('my_region.fits', 1)
62+
>>> wcs = WCS(header, keysel=('image', 'binary', 'pixel'))
63+
>>> sky_region = pix_region.to_sky(wcs)
64+
65+
66+
Other Limitations
67+
-----------------
68+
69+
* Reading and then writing a FITS region file will not produce an
70+
identical file to the original, but the encoded regions are identical.
71+
Therefore, it will produce identical `~regions.Region` objects
72+
when read back in again. In other words, read/write/read (or
73+
parse/serialize/parse) will exactly roundtrip `~regions.Region`
74+
objects.

docs/region_io.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,4 @@ Region File Format Limitations
180180
:maxdepth: 1
181181

182182
ds9_io
183+
fits_io

docs/shapes.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,9 @@ The text regions can be used to annotate a text string on an image.
224224
>>> from regions import PixCoord, TextSkyRegion, TextPixelRegion
225225
226226
>>> center_sky = SkyCoord(42, 43, unit='deg', frame='fk5')
227-
>>> point_sky = TextSkyRegion(center=center_sky, text='Demo Text')
228-
>>> point_pix = TextPixelRegion(center=PixCoord(x=42, y=43),
229-
... text='Demo Text')
227+
>>> region_sky = TextSkyRegion(center=center_sky, text='Demo Text')
228+
>>> region_pix = TextPixelRegion(center=PixCoord(x=42, y=43),
229+
... text='Demo Text')
230230
231231
232232
Region Transformations

regions/core/metadata.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ class RegionMeta(Meta):
5353
A dictionary subclass that holds the meta attributes of the region.
5454
"""
5555

56-
valid_keys = ['background', 'comment', 'composite', 'corr', 'delete',
57-
'edit', 'fixed', 'frame', 'highlite', 'include', 'label',
58-
'line', 'move', 'name', 'range', 'restfreq', 'rotate',
59-
'select', 'source', 'tag', 'text', 'textrotate', 'type',
60-
'veltype']
56+
valid_keys = ['background', 'comment', 'component', 'composite', 'corr',
57+
'delete', 'edit', 'fixed', 'frame', 'highlite', 'include',
58+
'label', 'line', 'move', 'name', 'range', 'restfreq',
59+
'rotate', 'select', 'source', 'tag', 'text', 'textrotate',
60+
'type', 'veltype']
6161

6262
key_mapping = {}
6363

regions/io/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
"""
33
This subpackage provides tools for reading and writing region files.
44
"""
5-
from .core import * # noqa
6-
75
from .ds9.connect import * # noqa
86
from .ds9.core import * # noqa
97
from .ds9.read import * # noqa

regions/io/core.py renamed to regions/io/crtf/io_core.py

Lines changed: 16 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,23 @@
55

66
from astropy.coordinates import (Angle, SkyCoord, UnitSphericalRepresentation,
77
frame_transform_graph)
8-
from astropy.table import Table
98
import astropy.units as u
109
from astropy.utils.exceptions import AstropyUserWarning
11-
import numpy as np
12-
13-
from ..shapes import (CirclePixelRegion, CircleSkyRegion,
14-
EllipsePixelRegion, EllipseSkyRegion,
15-
RectanglePixelRegion, RectangleSkyRegion,
16-
PolygonPixelRegion, RegularPolygonPixelRegion,
17-
PolygonSkyRegion,
18-
CircleAnnulusPixelRegion, CircleAnnulusSkyRegion,
19-
EllipseAnnulusPixelRegion, EllipseAnnulusSkyRegion,
20-
RectangleAnnulusPixelRegion, RectangleAnnulusSkyRegion,
21-
LinePixelRegion, LineSkyRegion,
22-
PointPixelRegion, PointSkyRegion,
23-
TextPixelRegion, TextSkyRegion)
24-
from ..core.core import PixCoord, SkyRegion
25-
from ..core.metadata import RegionMeta, RegionVisual
26-
from .crtf.core import CRTFRegionParserWarning
10+
11+
from ...shapes import (CirclePixelRegion, CircleSkyRegion,
12+
EllipsePixelRegion, EllipseSkyRegion,
13+
RectanglePixelRegion, RectangleSkyRegion,
14+
PolygonPixelRegion, RegularPolygonPixelRegion,
15+
PolygonSkyRegion,
16+
CircleAnnulusPixelRegion, CircleAnnulusSkyRegion,
17+
EllipseAnnulusPixelRegion, EllipseAnnulusSkyRegion,
18+
RectangleAnnulusPixelRegion, RectangleAnnulusSkyRegion,
19+
LinePixelRegion, LineSkyRegion,
20+
PointPixelRegion, PointSkyRegion,
21+
TextPixelRegion, TextSkyRegion)
22+
from ...core.core import PixCoord, SkyRegion
23+
from ...core.metadata import RegionMeta, RegionVisual
24+
from ..crtf.core import CRTFRegionParserWarning
2725

2826
__all__ = []
2927

@@ -44,20 +42,14 @@
4442

4543
# Map the region names in the respective format to the ones available in
4644
# this package
47-
reg_mapping = {'CRTF': {x: x for x in regions_attributes},
48-
'FITS_REGION': {x: x for x in regions_attributes}}
49-
45+
reg_mapping = {'CRTF': {x: x for x in regions_attributes}}
5046
reg_mapping['CRTF']['rotbox'] = 'rectangle'
5147
reg_mapping['CRTF']['box'] = 'rectangle'
5248
reg_mapping['CRTF']['centerbox'] = 'rectangle'
5349
reg_mapping['CRTF']['poly'] = 'polygon'
5450
reg_mapping['CRTF']['symbol'] = 'point'
5551
reg_mapping['CRTF']['text'] = 'text'
5652
reg_mapping['CRTF']['annulus'] = 'circleannulus'
57-
reg_mapping['FITS_REGION']['annulus'] = 'circleannulus'
58-
reg_mapping['FITS_REGION']['box'] = 'rectangle'
59-
reg_mapping['FITS_REGION']['rotbox'] = 'rectangle'
60-
reg_mapping['FITS_REGION']['elliptannulus'] = 'ellipseannulus'
6153

6254
# valid astropy coordinate frames in their respective formats
6355
valid_coordsys = {'CRTF': ['image', 'fk5', 'fk4', 'galactic',
@@ -271,78 +263,6 @@ def to_crtf(self, coordsys='fk5', fmt='.6f', radunit='deg'):
271263

272264
return output
273265

274-
def to_fits(self):
275-
"""
276-
Convert to a `~astropy.table.Table` object.
277-
"""
278-
max_length_coord = 1
279-
coord_x = []
280-
coord_y = []
281-
shapes = []
282-
radius = []
283-
rotangle_deg = []
284-
components = []
285-
286-
reg_reverse_mapping = {value: key for key, value in
287-
reg_mapping['FITS_REGION'].items()}
288-
reg_reverse_mapping['rectangle'] = 'ROTBOX'
289-
reg_reverse_mapping['circleannulus'] = 'ANNULUS'
290-
reg_reverse_mapping['ellipseannulus'] = 'ELLIPTANNULUS'
291-
292-
for num, shape in enumerate(self):
293-
shapes.append(reg_reverse_mapping[shape.region_type])
294-
if shape.region_type == 'polygon':
295-
max_length_coord = max(len(shape.coord) / 2, max_length_coord)
296-
coord = [x.value for x in shape.coord]
297-
coord_x.append(coord[::2])
298-
coord_y.append(coord[1::2])
299-
radius.append(0)
300-
rotangle_deg.append(0)
301-
else:
302-
coord_x.append(shape.coord[0].value)
303-
coord_y.append(shape.coord[1].value)
304-
if shape.region_type in ['circle', 'circleannulus', 'point']:
305-
radius.append([float(val) for val in shape.coord[2:]])
306-
rotangle_deg.append(0)
307-
else:
308-
radius.append([float(x) for x in shape.coord[2:-1]])
309-
rotangle_deg.append(shape.coord[-1].to('deg').value)
310-
311-
tag = shape.meta.get('tag', '')
312-
if tag.isdigit():
313-
components.append(int(tag))
314-
else:
315-
components.append(num + 1)
316-
317-
# pad every value with zeros at the end to make sure that all
318-
# values in the column have same length
319-
for i in range(len(self)):
320-
if np.isscalar(coord_x[i]):
321-
coord_x[i] = np.array([coord_x[i]])
322-
if np.isscalar(coord_y[i]):
323-
coord_y[i] = np.array([coord_y[i]])
324-
if np.isscalar(radius[i]):
325-
radius[i] = np.array([radius[i]])
326-
327-
coord_x[i] = np.pad(coord_x[i],
328-
(0, int(max_length_coord - len(coord_x[i]))),
329-
'constant', constant_values=(0, 0))
330-
coord_y[i] = np.pad(coord_y[i],
331-
(0, int(max_length_coord - len(coord_y[i]))),
332-
'constant', constant_values=(0, 0))
333-
radius[i] = np.pad(radius[i], (0, 4 - len(radius[i])), 'constant',
334-
constant_values=(0, 0))
335-
336-
table = Table([coord_x, coord_y, shapes, radius, rotangle_deg,
337-
components],
338-
names=('X', 'Y', 'SHAPE', 'R', 'ROTANG', 'COMPONENT'))
339-
table['X'].unit = 'pix'
340-
table['Y'].unit = 'pix'
341-
table['R'].unit = 'pix'
342-
table['ROTANG'].unit = 'deg'
343-
344-
return table
345-
346266

347267
class _Shape:
348268
"""

regions/io/crtf/read.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from ...core import Regions
1313
from ...core.registry import RegionsRegistry
14-
from ..core import _Shape, _ShapeList, reg_mapping
14+
from .io_core import _Shape, _ShapeList, reg_mapping
1515
from .core import (CRTFRegionParserError, CRTFRegionParserWarning,
1616
valid_symbols)
1717

regions/io/tests/test_core.py renamed to regions/io/crtf/tests/test_io_core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from astropy.units.quantity import Quantity
55
import pytest
66

7-
from ..core import _to_shape_list
8-
from ..crtf.read import _CRTFParser
7+
from ..io_core import _to_shape_list
8+
from ..read import _CRTFParser
99

1010

1111
def test_shape_crtf():

regions/io/crtf/write.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from ...core import Region, Regions
66
from ...core.registry import RegionsRegistry
7-
from ..core import _to_shape_list
7+
from .io_core import _to_shape_list
88

99
__all__ = []
1010

0 commit comments

Comments
 (0)