Repository: sunpy/sunpy

Issue:
Add the ability to extract intensity along a coordinate using Bresenham's line algorithm 
### Describe the feature

We should add a map utility function that takes in a `SkyCoord` and map and returns the intensity along that coordinate. One way of doing this is using [Bresenham's line algorithm](https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm) to find the integer pixels that overlap this coordinate.

### Proposed solution

```python
def bresenham(x1, y1, x2, y2):
    """
    Returns an array of all pixel coordinates which the line defined by `x1, y1` and
    `x2, y2` crosses. Uses Bresenham's line algorithm to enumerate the pixels along
    a line. This was adapted from ginga

    Parameters
    ----------
    x1, y1, x2, y2 :`int`

    References
    ----------
    | https://github.com/ejeschke/ginga/blob/master/ginga/BaseImage.py#L387
    | http://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
    | https://ginga.readthedocs.org/en/latest/
    """
    for x in [x1, y1, x2, y2]:
        if type(x) not in (int, np.int64):
            raise TypeError('All pixel coordinates must be of type int')
    dx = abs(x2 - x1)
    dy = abs(y2 - y1)
    sx = 1 if x1 < x2 else -1
    sy = 1 if y1 < y2 else -1
    err = dx - dy
    res = []
    x, y = x1, y1
    while True:
        res.append((x, y))
        if (x == x2) and (y == y2):
            break
        e2 = 2 * err
        if e2 > -dy:
            err = err - dy
            x += sx
        if e2 <  dx:
            err = err + dx
            y += sy
    return np.array(res)


def get_intersecting_pixels(map, coord):
    # Find pixels between each loop segment
    px, py = map.wcs.world_to_pixel(coord)
    px = np.round(px).astype(int)
    py = np.round(py).astype(int)
    loop_pix = []
    for i in range(px.shape[0]-1):
        b = bresenham(px[i], py[i], px[i+1], py[i+1])
        # Pop the last one, unless this is the final entry because the first point
        # of the next section will be the same
        if i < px.shape[0]-2:
            b = b[:-1]
        loop_pix.append(b)
    loop_pix = np.vstack(loop_pix)

    intensity = m.data[pix[:,1], pix[:,0]] * m.unit
    loop_coord = m.pixel_to_world(pix[:,0]*u.pix, pix[:,1]*u.pix)

    return intensity, loop_coord
```


Discussion:
I think that we should probably but the `bresenham` function in `sunpy/image` and the `get_intersecting_pixels` function in `sunpy/map/maputils.py`?

Resolving patch:
diff --git a/examples/units_and_coordinates/map_slit_extraction.py b/examples/units_and_coordinates/map_slit_extraction.py
new file mode 100644
--- /dev/null
+++ b/examples/units_and_coordinates/map_slit_extraction.py
@@ -0,0 +1,61 @@
+"""
+==========================================
+Extracting intensity of a map along a line
+==========================================
+
+In this example we will extract the intensity values of all the pixels
+that intersect with a given set of coordinates.
+"""
+import matplotlib.pyplot as plt
+
+import astropy.units as u
+from astropy.coordinates import SkyCoord
+
+import sunpy.map
+from sunpy.data.sample import AIA_171_ROLL_IMAGE
+
+###############################################################################
+# First we construct a map, using some sample data.
+aia_map = sunpy.map.Map(AIA_171_ROLL_IMAGE)
+
+
+###############################################################################
+# Next we define a path in a `~astropy.coordinates.SkyCoord` object.
+# In this example we are just going to use a straight line defined by two points.
+# However a path with any number of points and any shape can be used.
+line_coords = SkyCoord([-1024, -908], [20, 633], unit=(u.arcsec, u.arcsec),
+                       frame=aia_map.coordinate_frame)
+
+
+###############################################################################
+# Next we call the `sunpy.map.extract_along_coord` function with the map and
+# the coordinates we want to extract.
+# This function returns two items, the first is a numpy array of all the
+# intensities and the second is a `~astropy.coordinates.SkyCoord` object of the
+# same length, which describes the world coordinates of each pixel that has
+# been extracted.
+intensity, intensity_coords = sunpy.map.extract_along_coord(aia_map, line_coords)
+
+
+###############################################################################
+# Next we will calculate the angular separation between the first point and
+# every other coordinate we extracted. We are doing this to give us a
+# meaningful x-axis for our line plot below.
+angular_separation = intensity_coords.separation(intensity_coords[0]).to(u.arcsec)
+
+###############################################################################
+# Finally let's plot the results.
+fig = plt.figure()
+ax1 = fig.add_subplot(121, projection=aia_map)
+aia_map.plot(axes=ax1)
+ax1.plot_coord(intensity_coords)
+ax1.plot_coord(line_coords[0], marker="o", color="blue", label="start")
+ax1.plot_coord(line_coords[1], marker="o", color="green", label="end")
+ax1.legend()
+
+ax2 = fig.add_subplot(122)
+ax2.plot(angular_separation, intensity)
+ax2.set_xlabel("Angular distance along slit [arcsec]")
+ax2.set_ylabel(f"Intensity [{aia_map.unit}]")
+
+plt.show()
diff --git a/sunpy/map/maputils.py b/sunpy/map/maputils.py
--- a/sunpy/map/maputils.py
+++ b/sunpy/map/maputils.py
@@ -1,6 +1,7 @@
 """
 This submodule provides utility functions to act on `sunpy.map.GenericMap` instances.
 """
+import numbers
 from itertools import product
 
 import numpy as np
@@ -16,7 +17,8 @@
            'contains_full_disk', 'is_all_off_disk', 'is_all_on_disk',
            'contains_limb', 'coordinate_is_on_solar_disk',
            'on_disk_bounding_coordinates',
-           'contains_coordinate', 'contains_solar_center']
+           'contains_coordinate', 'contains_solar_center',
+           'extract_along_coord']
 
 
 def all_pixel_indices_from_map(smap):
@@ -410,3 +412,90 @@ def contains_coordinate(smap, coordinates):
             (xc <= xs - point5pix) &
             (yc >= -point5pix) &
             (yc <= ys - point5pix))
+
+
+def _bresenham(*, x1, y1, x2, y2):
+    """
+    Returns an array of all pixel coordinates which the line defined by `x1, y1` and
+    `x2, y2` crosses. Uses Bresenham's line algorithm to enumerate the pixels along
+    a line. This was adapted from ginga.
+
+    Parameters
+    ----------
+    x1, y1, x2, y2 :`int`
+
+    References
+    ----------
+    * https://github.com/ejeschke/ginga/blob/c8ceaf8e559acc547bf25661842a53ed44a7b36f/ginga/BaseImage.py#L503
+    * http://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
+    """
+    for x in [x1, y1, x2, y2]:
+        if not isinstance(x, numbers.Integral):
+            raise TypeError('All pixel coordinates must be of type int')
+    dx = abs(x2 - x1)
+    dy = abs(y2 - y1)
+    sx = 1 if x1 < x2 else -1
+    sy = 1 if y1 < y2 else -1
+    err = dx - dy
+    res = []
+    x, y = x1, y1
+    while True:
+        res.append((x, y))
+        if (x == x2) and (y == y2):
+            break
+        e2 = 2 * err
+        if e2 > -dy:
+            err = err - dy
+            x += sx
+        if e2 < dx:
+            err = err + dx
+            y += sy
+    return np.array(res)
+
+
+def extract_along_coord(smap, coord):
+    """
+    Return the value of the image array at every pixel the coordinate path intersects.
+
+    For a given coordinate ``coord``, find all the pixels that cross the coordinate
+    and extract the values of the image array in ``smap`` at these points. This is done by applying
+    `Bresenham's line algorithm <http://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm>`_
+    between the consecutive coordinates, in pixel space, and then indexing the data
+    array of ``smap`` at those points.
+
+    Parameters
+    ----------
+    smap : `~sunpy.map.GenericMap`
+        The sunpy map.
+    coord : `~astropy.coordinates.SkyCoord`
+        Coordinate along which to extract intensity.
+
+    Returns
+    -------
+    values : `~astropy.units.Quantity`
+    value_coords : `~astropy.coordinates.SkyCoord`
+    """
+    if not len(coord.shape) or coord.shape[0] < 2:
+        raise ValueError('At least two points are required for extracting intensity along a '
+                         'line. To extract points at single coordinates, use '
+                         'sunpy.map.maputils.sample_at_coords.')
+    if not all(contains_coordinate(smap, coord)):
+        raise ValueError('At least one coordinate is not within the bounds of the map.'
+                         'To extract the intensity along a coordinate, all points must fall within '
+                         'the bounds of the map.')
+    # Find pixels between each loop segment
+    px, py = smap.wcs.world_to_array_index(coord)
+    pix = []
+    for i in range(len(px)-1):
+        b = _bresenham(x1=px[i], y1=py[i], x2=px[i+1], y2=py[i+1])
+        # Pop the last one, unless this is the final entry because the first point
+        # of the next section will be the same
+        if i < (len(px) - 2):
+            b = b[:-1]
+        pix.append(b)
+    pix = np.vstack(pix)
+
+    intensity = u.Quantity(smap.data[pix[:, 0], pix[:, 1]], smap.unit)
+    coord_new = smap.wcs.pixel_to_world(pix[:, 1], pix[:, 0])
+
+    return intensity, coord_new


Test patch:
diff --git a/sunpy/map/tests/test_maputils.py b/sunpy/map/tests/test_maputils.py
--- a/sunpy/map/tests/test_maputils.py
+++ b/sunpy/map/tests/test_maputils.py
@@ -19,6 +19,7 @@
     contains_limb,
     contains_solar_center,
     coordinate_is_on_solar_disk,
+    extract_along_coord,
     is_all_off_disk,
     is_all_on_disk,
     map_edges,
@@ -272,3 +273,32 @@ def test_contains_coord(aia171_test_map):
     multi_coord = SkyCoord([0, 2000]*u.arcsec, [0, 2000]*u.arcsec,
                            frame=smap.coordinate_frame)
     assert (contains_coordinate(smap, multi_coord) == [True, False]).all()
+
+
+def test_extract_along_coord(aia171_test_map):
+    nmax = max(*aia171_test_map.data.shape)
+    top_right = aia171_test_map.pixel_to_world((nmax-1)*u.pix, (nmax-1)*u.pix)
+    line = SkyCoord([aia171_test_map.bottom_left_coord, top_right])
+    intensity, line_discrete = extract_along_coord(aia171_test_map, line)
+    pix_diag = np.array([(i, i) for i in range(nmax)])
+    intensity_diag = u.Quantity(
+        [aia171_test_map.data[i[0], i[1]] for i in pix_diag],
+        aia171_test_map.unit
+    )
+    line_discrete_pix = aia171_test_map.world_to_pixel(line_discrete)
+    assert np.allclose(pix_diag[:, 0], line_discrete_pix.x.value)
+    assert np.allclose(pix_diag[:, 1], line_discrete_pix.y.value)
+    assert u.quantity.allclose(intensity_diag, intensity)
+
+
+def test_extract_along_coord_one_point_exception(aia171_test_map):
+    with pytest.raises(ValueError, match='At least two points are required*'):
+        _ = extract_along_coord(aia171_test_map, aia171_test_map.bottom_left_coord)
+    with pytest.raises(ValueError, match='At least two points are required*'):
+        _ = extract_along_coord(aia171_test_map, SkyCoord([aia171_test_map.bottom_left_coord]))
+
+
+def test_extract_along_coord_out_of_bounds_exception(aia171_test_map):
+    point = aia171_test_map.pixel_to_world([-1, 1]*u.pix, [-1, 1]*u.pix)
+    with pytest.raises(ValueError, match='At least one coordinate is not within the bounds of the map.*'):
+        _ = extract_along_coord(aia171_test_map, point)