Skip to content

New alignment procedure #1308

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 267 additions & 5 deletions src/panoptes/pocs/utils/alignment.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,46 @@
from dataclasses import dataclass
from pathlib import Path

import numpy as np
from astropy.coordinates import SkyCoord
from astropy.nddata import Cutout2D
from astropy.visualization import SqrtStretch
from astropy.visualization import LogStretch, SqrtStretch
from astropy.visualization.mpl_normalize import ImageNormalize
from astropy.wcs import WCS
from matplotlib import pyplot as plt
from panoptes.utils.images.fits import get_solve_field, getdata
from matplotlib.figure import Figure
from matplotlib.patches import Circle
from panoptes.utils.error import PanError
from panoptes.utils.images import fits
from panoptes.utils.images.fits import get_solve_field, get_wcsinfo, getdata
from rich import print
from skimage.feature import canny
from skimage.transform import hough_circle, hough_circle_peaks


def analyze_polar_rotation(pole_fn: Path | str, **kwargs):
def get_celestial_center(pole_fn: Path | str, **kwargs):
"""Analyze the polar rotation image to get the center of the pole.

Args:
pole_fn (Path | str): FITS file of polar center
Returns:
tuple(int): Polar center XY coordinates
"""
if isinstance(pole_fn, Path):
pole_fn = pole_fn.as_posix()

Check warning on line 30 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L30

Added line #L30 was not covered by tests

get_solve_field(pole_fn, **kwargs)

wcs = WCS(pole_fn.as_posix())
wcs = WCS(pole_fn)

Check warning on line 34 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L34

Added line #L34 was not covered by tests

pole_cx, pole_cy = wcs.all_world2pix(360, 90, 1)

return pole_cx, pole_cy
wcsinfo = get_wcsinfo(pole_fn)
pixscale = None

Check warning on line 39 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L38-L39

Added lines #L38 - L39 were not covered by tests
if wcsinfo is not None:
pixscale = wcsinfo['pixscale'].value

Check warning on line 41 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L41

Added line #L41 was not covered by tests

return pole_cx, pole_cy, pixscale

Check warning on line 43 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L43

Added line #L43 was not covered by tests


def analyze_ra_rotation(rotate_fn: Path | str):
Expand Down Expand Up @@ -57,6 +72,95 @@
return d1.to_original_position((rotate_cx[-1], rotate_cy[-1]))


@dataclass
class AlignmentResult:
pole_center: tuple[float, float]
rotate_center: tuple[float, float]
rotate_radius: float
pix_scale: float
target_points: dict[str, tuple[float, float]]
target_name: str
dx_deg: float
dy_deg: float

def __str__(self):
# Pretty print
return (f"Celestial Center: {self.pole_center[0]:.2f}, {self.pole_center[1]:.2f}\n"

Check warning on line 88 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L88

Added line #L88 was not covered by tests
f"Rotate Center: {self.rotate_center[0]:.2f}, {self.rotate_center[1]:.2f}\n"
f"Rotate Radius: {self.rotate_radius:.02f}\n"
f"Pixel Scale: {self.pix_scale:.02f}\n"
f"Target Name: {self.target_name}\n"
f"Target Points: {[(n, (int(p[0]), int(p[1]))) for n, p in self.target_points.items()]}\n"
f"Delta (degrees): {self.dx_deg:.02f} {self.dy_deg:.02f}\n"
)


def process_quick_alignment(files: dict[str, Path], target_name: str = 'Polaris') -> AlignmentResult:
"""Process the quick alignment of polar rotation and RA rotation images.

Args:
files (dict[str, Path]): Dictionary of image positions and their FITS file paths.

Returns:
tuple: Polar center coordinates, RA rotation center coordinates, dx, dy, pixel scale
"""
# Get coordinates for Polaris in each of the images.
target = SkyCoord.from_name(target_name)

Check warning on line 108 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L108

Added line #L108 was not covered by tests

points = dict()
pole_center = None
pix_scale = None

Check warning on line 112 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L110-L112

Added lines #L110 - L112 were not covered by tests
# Find the xy-coords of Polaris in each of the images using the wcs.
for position, fits_fn in files.items():
if not isinstance(fits_fn, Path):
fits_fn = Path(fits_fn)

Check warning on line 116 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L116

Added line #L116 was not covered by tests

if position == 'home':
print(f"Processing polar rotation image: {fits_fn}")
pole_center_x, pole_center_y, pix_scale = get_celestial_center(fits_fn)
pole_center = (float(pole_center_x), float(pole_center_y))

Check warning on line 121 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L119-L121

Added lines #L119 - L121 were not covered by tests
else:
try:
print(f"Processing RA rotation image: {fits_fn}")
solve_info = get_solve_field(fits_fn.as_posix())
except PanError:
print(f"Unable to solve image {fits_fn}")
continue

Check warning on line 128 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L123-L128

Added lines #L123 - L128 were not covered by tests
else:
# Get the pixel coordinates of Polaris in the image.
wcs = WCS(fits_fn.as_posix())
x, y = wcs.all_world2pix(target.ra.deg, target.dec.deg, 1)
points[position] = (x, y)

Check warning on line 133 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L131-L133

Added lines #L131 - L133 were not covered by tests

# Find the circle that best fits the points.
h, k, R = find_circle_params(points)
rotate_center = (h, k)

Check warning on line 137 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L136-L137

Added lines #L136 - L137 were not covered by tests

dx = None
dy = None

Check warning on line 140 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L139-L140

Added lines #L139 - L140 were not covered by tests

# Get the distance from the center of the circle to the center of celestial pole.
if pole_center is not None:
dx = pole_center[0] - rotate_center[0]
dy = pole_center[1] - rotate_center[1]

Check warning on line 145 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L144-L145

Added lines #L144 - L145 were not covered by tests

# Convert deltas to degrees.
if pix_scale is not None:
dx = dx * pix_scale / 3600
dy = dy * pix_scale / 3600

Check warning on line 150 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L149-L150

Added lines #L149 - L150 were not covered by tests

return AlignmentResult(

Check warning on line 152 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L152

Added line #L152 was not covered by tests
pole_center=pole_center,
rotate_center=rotate_center,
rotate_radius=R,
dx_deg=dx,
dy_deg=dy,
pix_scale=pix_scale,
target_points=points,
target_name=target_name
)


def plot_center(pole_fn, rotate_fn, pole_center, rotate_center):
""" Overlay the celestial pole and RA rotation axis images.

Expand Down Expand Up @@ -110,3 +214,161 @@
ax.set_title(f"dx: {d_x:0.2f} pix dy: {d_y:0.2f} pix")

return fig


def find_circle_params(points: dict[str, tuple[float, float]]) -> tuple[float, float, float]:
"""
Calculates the center (h, k) and radius (R) of a circle given a list of points.

Args:
points: A dictionary with keys as position names and values as tuples of (x, y) coordinates.

Returns:
A tuple (h, k, R) representing the center and radius of the circle.
Returns (None, None, None) if the input is invalid or no circle can be found.
"""
if len(points) < 3:
print("Error: Input must be a list of at least three points.")
return None, None, None

Check warning on line 232 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L231-L232

Added lines #L231 - L232 were not covered by tests

# Extract x and y coordinates
x_coords = [p[0] for p in points.values()]
y_coords = [p[1] for p in points.values()]

Check warning on line 236 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L235-L236

Added lines #L235 - L236 were not covered by tests

# Construct the matrix A and vector b for the system of equations
A = np.array(

Check warning on line 239 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L239

Added line #L239 was not covered by tests
[
[x_coords[0], y_coords[0], 1],
[x_coords[1], y_coords[1], 1],
[x_coords[2], y_coords[2], 1]
]
)
b = np.array(

Check warning on line 246 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L246

Added line #L246 was not covered by tests
[
-(x_coords[0] ** 2 + y_coords[0] ** 2),
-(x_coords[1] ** 2 + y_coords[1] ** 2),
-(x_coords[2] ** 2 + y_coords[2] ** 2)
]
)

# Solve the system of equations Ax = b for the coefficients D, E, and F
try:
x = np.linalg.solve(A, b)
D, E, F = x
except np.linalg.LinAlgError:
print("Error: Points are collinear or do not form a unique circle.")
return None, None, None

Check warning on line 260 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L255-L260

Added lines #L255 - L260 were not covered by tests

# Calculate the center (h, k) and radius (R)
h = -D / 2
k = -E / 2
discriminant = h ** 2 + k ** 2 - F

Check warning on line 265 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L263-L265

Added lines #L263 - L265 were not covered by tests
if discriminant < 0:
print("Error: Invalid circle parameters, negative value under square root.")
return None, None, None
R = np.sqrt(discriminant)
return h, k, R

Check warning on line 270 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L267-L270

Added lines #L267 - L270 were not covered by tests


def plot_alignment_diff(cam_name: str, files: dict[str, str | Path], results: AlignmentResult):
pole_cx, pole_cy = results.pole_center
rotate_cx, rotate_cy = results.rotate_center

Check warning on line 275 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L274-L275

Added lines #L274 - L275 were not covered by tests

data0 = fits.getdata(files['home'])
wcs0 = fits.getwcs(files['home'])

Check warning on line 278 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L277-L278

Added lines #L277 - L278 were not covered by tests

# Create figure
fig = Figure(figsize=(20, 14), layout='constrained')
ax = fig.add_subplot(1, 1, 1, projection=wcs0)

Check warning on line 282 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L281-L282

Added lines #L281 - L282 were not covered by tests

alpha = 0.3
number_ticks = 9

Check warning on line 285 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L284-L285

Added lines #L284 - L285 were not covered by tests

ax.grid(True, color='blue', ls='-', alpha=alpha)

Check warning on line 287 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L287

Added line #L287 was not covered by tests

ra_axis = ax.coords['ra']
ra_axis.set_axislabel('Right Ascension')
ra_axis.set_major_formatter('hh:mm')
ra_axis.set_ticks(number=number_ticks, color='cyan')

Check warning on line 292 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L289-L292

Added lines #L289 - L292 were not covered by tests
# ra_axis.set_ticklabel(color='white', exclude_overlapping=True)

dec_axis = ax.coords['dec']
dec_axis.set_axislabel('Declination')
dec_axis.set_major_formatter('dd:mm')
dec_axis.set_ticks(number=number_ticks, color='cyan')

Check warning on line 298 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L295-L298

Added lines #L295 - L298 were not covered by tests
# dec_axis.set_ticklabel(color='white', exclude_overlapping=True)

# Show both images in background
norm = ImageNormalize(stretch=LogStretch())

Check warning on line 302 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L302

Added line #L302 was not covered by tests

# Replace negative values with zero.
data0[data0 < 0] = 0

Check warning on line 305 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L305

Added line #L305 was not covered by tests

# Get the delta in pixels.
delta_cx = pole_cx - rotate_cx
delta_cy = pole_cy - rotate_cy

Check warning on line 309 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L308-L309

Added lines #L308 - L309 were not covered by tests

# Show the background
im = ax.imshow(data0, cmap='Greys_r', norm=norm)

Check warning on line 312 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L312

Added line #L312 was not covered by tests

# Show the detected points.
for pos, (x, y) in results.target_points.items():
ax.scatter(x, y, marker='o', ec='coral', fc='none', lw=2, label=f"{results.target_name} {pos}")
ax.annotate(pos, (x, y), c='coral', xytext=(3, 3), textcoords='offset pixels')

Check warning on line 317 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L316-L317

Added lines #L316 - L317 were not covered by tests

# Show the rotation center.
ax.scatter(rotate_cx, rotate_cy, marker='*', c='coral', zorder=200, label='Center of mount rotation')

Check warning on line 320 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L320

Added line #L320 was not covered by tests

# Show the rotation circle
ax.add_patch(

Check warning on line 323 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L323

Added line #L323 was not covered by tests
Circle(
(results.rotate_center[0], results.rotate_center[1]), results.rotate_radius, color='coral', fill=False,
alpha=0.5, label='Circle of mount rotation', zorder=200
)
)

# Arrow from rotation center to celestial center.
move_arrow = None

Check warning on line 331 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L331

Added line #L331 was not covered by tests
if (np.abs(delta_cy) > 25) or (np.abs(delta_cx) > 25):
move_arrow = ax.arrow(

Check warning on line 333 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L333

Added line #L333 was not covered by tests
rotate_cx,
rotate_cy,
delta_cx,
delta_cy,
fc='r',
ec='r',
width=10,
length_includes_head=True
)

# Arrow for rotation radius.
ax.arrow(

Check warning on line 345 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L345

Added line #L345 was not covered by tests
results.rotate_center[0], results.rotate_center[1], -results.rotate_radius, 0, color='pink',
length_includes_head=True, width=10, alpha=0.25
)

# Arrow for required mount motion.
ax.arrow(

Check warning on line 351 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L351

Added line #L351 was not covered by tests
results.rotate_center[0], results.rotate_center[1], delta_cx, delta_cy, color='red', length_includes_head=True,
width=10, zorder=101
)

# Show the celestial center
ax.scatter(pole_cx, pole_cy, marker='*', c='blue', label='Center of celestial sphere')

Check warning on line 357 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L357

Added line #L357 was not covered by tests

# Get the handles and labels from the existing legend
handles, labels = ax.get_legend_handles_labels()

Check warning on line 360 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L360

Added line #L360 was not covered by tests

# Add the new handle and label
if move_arrow is not None:
handles.append(move_arrow)
labels.append('Direction to move mount')

Check warning on line 365 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L364-L365

Added lines #L364 - L365 were not covered by tests

# Call legend() again to update the legend
ax.legend(handles, labels, loc='upper right')

Check warning on line 368 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L368

Added line #L368 was not covered by tests

title0 = (f'{delta_cx=:10.01f} pix RA ={results.dx_deg:10.02f} deg \n {delta_cy=:10.01f} pix Dec='

Check warning on line 370 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L370

Added line #L370 was not covered by tests
f'{results.dy_deg:10.02f} deg')
fig.suptitle(f'{cam_name}\n{title0}', y=0.93)

Check warning on line 372 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L372

Added line #L372 was not covered by tests

return fig

Check warning on line 374 in src/panoptes/pocs/utils/alignment.py

View check run for this annotation

Codecov / codecov/patch

src/panoptes/pocs/utils/alignment.py#L374

Added line #L374 was not covered by tests
Loading