diff --git a/astro_data/pifinder_objects.db b/astro_data/pifinder_objects.db index bd52f27df..de45950d8 100644 Binary files a/astro_data/pifinder_objects.db and b/astro_data/pifinder_objects.db differ diff --git a/default_config.json b/default_config.json index 7ce509784..60a723e84 100644 --- a/default_config.json +++ b/default_config.json @@ -16,6 +16,8 @@ "chart_dso": 128, "chart_reticle": 128, "chart_constellations": 64, + "image_nsew": true, + "image_bbox": true, "solve_pixel": [256, 256], "gps_type": "ublox", "gps_baud_rate": 9600, diff --git a/python/PiFinder/cat_images.py b/python/PiFinder/cat_images.py index ae4a9b967..c85d31387 100644 --- a/python/PiFinder/cat_images.py +++ b/python/PiFinder/cat_images.py @@ -5,6 +5,7 @@ to handle catalog image loading """ +import math import os from PIL import Image, ImageChops, ImageDraw from PiFinder import image_util @@ -19,6 +20,55 @@ logger = logging.getLogger("Catalog.Images") +def cardinal_vectors(image_rotate, fx=1, fy=1): + """Return (nx, ny), (ex, ey) unit vectors for North and East. + + image_rotate: degrees the POSS image was rotated (180 + roll). + fx, fy: -1 to mirror that axis (flip/flop), +1 otherwise. + """ + theta = math.radians(image_rotate) + n = (fx * math.sin(theta), fy * -math.cos(theta)) + e = (-fx * math.cos(theta), -fy * math.sin(theta)) + return n, e + + +def size_overlay_points(extents, pa, image_rotate, px_per_arcsec, cx, cy, fx=1, fy=1): + """Compute outline points for the size overlay. + + Returns a list of (x, y) tuples. + For 1 extent returns None (caller should use native ellipse). + """ + if not extents or len(extents) == 1: + return None + + theta = math.radians(image_rotate - pa - 90) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + points = [] + if len(extents) == 2: + rx = extents[0] * px_per_arcsec / 2 + ry = extents[1] * px_per_arcsec / 2 + for i in range(36): + t = 2 * math.pi * i / 36 + x = rx * math.cos(t) + y = ry * math.sin(t) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + else: + step = 2 * math.pi / len(extents) + for i, ext in enumerate(extents): + angle = i * step - math.pi / 2 + r = ext * px_per_arcsec / 2 + x = r * math.cos(angle) + y = r * math.sin(angle) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + return points + + def get_display_image( catalog_object, eyepiece_text, @@ -27,6 +77,9 @@ def get_display_image( display_class, burn_in=True, magnification=None, + telescope=None, + show_nsew=True, + show_bbox=True, ): """ Returns a 128x128 image buffer for @@ -37,6 +90,8 @@ def get_display_image( roll: degrees """ + flip = telescope.flip_image if telescope else False + flop = telescope.flop_image if telescope else False object_image_path = resolve_image_name(catalog_object, source="POSS") logger.debug("object_image_path = %s", object_image_path) @@ -59,6 +114,10 @@ def get_display_image( image_rotate += roll return_image = return_image.rotate(image_rotate) + if flip: + return_image = return_image.transpose(Image.FLIP_LEFT_RIGHT) + if flop: + return_image = return_image.transpose(Image.FLIP_TOP_BOTTOM) # FOV fov_size = int(1024 * fov / 2) @@ -98,6 +157,78 @@ def get_display_image( width=1, ) + cx = display_class.fov_res / 2 + cy = display_class.fov_res / 2 + fx = -1 if flip else 1 + fy = -1 if flop else 1 + + # NSEW cardinal labels — show only 2: topmost and leftmost + if show_nsew: + (nx, ny), (ex, ey) = cardinal_vectors(image_rotate, fx, fy) + label_font = display_class.fonts.base + label_color = display_class.colors.get(64) + r_label = display_class.fov_res / 2 - 2 + top_limit = display_class.titlebar_height + bottom_limit = display_class.fov_res - label_font.height * 2 + + candidates = [ + ("N", nx, ny), + ("S", -nx, -ny), + ("E", ex, ey), + ("W", -ex, -ey), + ] + by_top = sorted(candidates, key=lambda c: c[2]) + by_left = sorted(candidates, key=lambda c: c[1]) + chosen = {by_top[0][0]: by_top[0]} + # pick leftmost that isn't already chosen + for c in by_left: + if c[0] not in chosen: + chosen[c[0]] = c + break + + for label, dx, dy in chosen.values(): + lx = cx + dx * r_label - label_font.width / 2 + ly = cy + dy * r_label - label_font.height / 2 + lx = max(0, min(lx, display_class.fov_res - label_font.width)) + ly = max(top_limit, min(ly, bottom_limit)) + ui_utils.shadow_outline_text( + ri_draw, + (lx, ly), + label, + font=label_font, + align="left", + fill=label_color, + shadow_color=display_class.colors.get(0), + outline=1, + ) + + # Size overlay + extents = catalog_object.size.extents + if show_bbox and extents and fov > 0: + px_per_arcsec = display_class.fov_res / (fov * 3600) + overlay_color = display_class.colors.get(100) + + if len(extents) == 1: + r = extents[0] * px_per_arcsec / 2 + ri_draw.ellipse( + [cx - r, cy - r, cx + r, cy + r], + outline=overlay_color, + width=1, + ) + else: + points = size_overlay_points( + extents, + catalog_object.size.position_angle, + image_rotate, + px_per_arcsec, + cx, + cy, + fx, + fy, + ) + if points: + ri_draw.polygon(points, outline=overlay_color) + # Pad out image if needed if display_class.fov_res != display_class.resX: pad_image = Image.new("RGB", display_class.resolution) diff --git a/python/PiFinder/catalog_imports/bright_stars_loader.py b/python/PiFinder/catalog_imports/bright_stars_loader.py index fa4dfc34e..b202b2a95 100644 --- a/python/PiFinder/catalog_imports/bright_stars_loader.py +++ b/python/PiFinder/catalog_imports/bright_stars_loader.py @@ -9,7 +9,7 @@ from tqdm import tqdm import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( NewCatalogObject, @@ -45,8 +45,7 @@ def load_bright_stars(): sequence = int(dfs[0]) logging.debug(f"---------------> Bright Stars {sequence=} <---------------") - size = "" - # const = dfs[2].strip() + size = SizeObject([]) desc = "" ra_h = int(dfs[3]) @@ -58,7 +57,6 @@ def load_bright_stars(): dec_deg = dec_to_deg(dec_d, dec_m, 0) mag = MagnitudeObject([float(dfs[7].strip())]) - # const = dfs[8] new_object = NewCatalogObject( object_type=obj_type, diff --git a/python/PiFinder/catalog_imports/caldwell_loader.py b/python/PiFinder/catalog_imports/caldwell_loader.py index 0e29f5135..ae25157ef 100644 --- a/python/PiFinder/catalog_imports/caldwell_loader.py +++ b/python/PiFinder/catalog_imports/caldwell_loader.py @@ -17,6 +17,7 @@ insert_catalog, insert_catalog_max_sequence, add_space_after_prefix, + parse_arcmin_size, ) # Import shared database object @@ -46,7 +47,7 @@ def load_caldwell(): mag = MagnitudeObject([]) else: mag = MagnitudeObject([float(mag)]) - size = dfs[5][5:].strip() + size = parse_arcmin_size(dfs[5][5:].strip()) ra_h = int(dfs[6]) ra_m = float(dfs[7]) ra_deg = ra_to_deg(ra_h, ra_m, 0) diff --git a/python/PiFinder/catalog_imports/catalog_import_utils.py b/python/PiFinder/catalog_imports/catalog_import_utils.py index 279169df3..a36dbb4e9 100644 --- a/python/PiFinder/catalog_imports/catalog_import_utils.py +++ b/python/PiFinder/catalog_imports/catalog_import_utils.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from tqdm import tqdm -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.ui.ui_utils import normalize from PiFinder import calc_utils from PiFinder.db.objects_db import ObjectsDatabase @@ -30,7 +30,7 @@ class NewCatalogObject: dec: float mag: MagnitudeObject object_id: int = 0 - size: str = "" + size: SizeObject = field(default_factory=lambda: SizeObject([])) description: str = "" aka_names: list[str] = field(default_factory=list) surface_brightness: float = 0.0 @@ -76,7 +76,7 @@ def insert(self, find_object_id=True): self.ra, self.dec, self.constellation, - self.size, + self.size.to_json(), self.mag.to_json(), self.surface_brightness, ) @@ -158,6 +158,22 @@ def get_object_id(self, object_name: str): return result +def parse_arcmin_size(raw: str) -> SizeObject: + """Parse a size string assumed to be in arcminutes. Handles 'NxM' format.""" + if not raw: + return SizeObject([]) + parts = raw.lower().replace("x", " ").split() + values = [] + for p in parts: + try: + values.append(float(p)) + except ValueError: + logging.warning("Non-numeric size token %r in %r", p, raw) + if not values: + return SizeObject([]) + return SizeObject.from_arcmin(*values) + + def safe_convert_to_float(x): """Convert to float, filtering out non-numeric values""" try: diff --git a/python/PiFinder/catalog_imports/harris_loader.py b/python/PiFinder/catalog_imports/harris_loader.py index e3635a3cd..e79150e5f 100644 --- a/python/PiFinder/catalog_imports/harris_loader.py +++ b/python/PiFinder/catalog_imports/harris_loader.py @@ -14,7 +14,7 @@ import numpy as np import numpy.typing as npt import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( delete_catalog_from_database, @@ -286,15 +286,13 @@ def create_cluster_object(entry: npt.NDArray, seq: int) -> Dict[str, Any]: logging.debug(f" Magnitude: None (invalid value: {mag_value})") # Size - use half-mass radius (Rh) in arcminutes - # Format using utils.format_size_value to match other catalogs rh = entry["Rh"].item() if is_valid_value(rh): - # Convert to string, removing unnecessary decimals - result["size"] = utils.format_size_value(rh) + result["size"] = SizeObject.from_arcmin(float(rh)) if VERBOSE: - logging.debug(f" Size (half-mass radius): {result['size']} arcmin") + logging.debug(f" Size (half-mass radius): {rh} arcmin") else: - result["size"] = "" + result["size"] = SizeObject([]) if VERBOSE: logging.debug(f" Size: None (invalid Rh value: {rh})") diff --git a/python/PiFinder/catalog_imports/herschel_loader.py b/python/PiFinder/catalog_imports/herschel_loader.py index f6f3c904e..4d2d7089d 100644 --- a/python/PiFinder/catalog_imports/herschel_loader.py +++ b/python/PiFinder/catalog_imports/herschel_loader.py @@ -54,9 +54,13 @@ def load_herschel400(): f"---------------> Herschel 400 {sequence=} <---------------" ) - object_id = objects_db.get_catalog_object_by_sequence( + result = objects_db.get_catalog_object_by_sequence( "NGC", NGC_sequence - )["id"] + ) + if result is None: + logging.warning("NGC %s not found, skipping H%d", NGC_sequence, sequence) + continue + object_id = result["id"] objects_db.insert_name(object_id, h_name, catalog) objects_db.insert_catalog_object(object_id, catalog, sequence, h_desc) conn.commit() diff --git a/python/PiFinder/catalog_imports/main.py b/python/PiFinder/catalog_imports/main.py index 4101e5d52..7d94df566 100644 --- a/python/PiFinder/catalog_imports/main.py +++ b/python/PiFinder/catalog_imports/main.py @@ -85,6 +85,8 @@ def main(): objects_db, _ = init_shared_database() logging.info("creating catalog tables") + conn, _ = objects_db.get_conn_cursor() + conn.execute("PRAGMA journal_mode = WAL") objects_db.destroy_tables() objects_db.create_tables() @@ -121,6 +123,12 @@ def main(): resolve_object_images() print_database() + # Finalize: checkpoint WAL and switch to DELETE mode so the .db is + # self-contained (no -wal/-shm sidecars needed at runtime). + logging.info("Finalizing database...") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode = DELETE") + if __name__ == "__main__": main() diff --git a/python/PiFinder/catalog_imports/post_processing.py b/python/PiFinder/catalog_imports/post_processing.py index 22fa3a8c0..d411547ce 100644 --- a/python/PiFinder/catalog_imports/post_processing.py +++ b/python/PiFinder/catalog_imports/post_processing.py @@ -11,7 +11,7 @@ # Import shared database object from .database import objects_db from .catalog_import_utils import NewCatalogObject -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject import PiFinder.utils as utils @@ -123,7 +123,7 @@ def add_missing_messier_objects(): ra=185.552, # 12h 22m 12.5272s in degrees dec=58.083, # +58° 4′ 58.549″ in degrees mag=MagnitudeObject([9.9]), # Average of components A (9.64) and B (10.11) - size="0.1'", + size=SizeObject.from_arcmin(0.1), description="Winnecke 4 double star", aka_names=m40_aka_names, ) @@ -143,7 +143,7 @@ def add_missing_messier_objects(): ra=56.85, # 03h 47m 24s in degrees dec=24.117, # +24° 07′ 00″ in degrees mag=MagnitudeObject([1.6]), - size="120'", # 2° = 120 arcminutes + size=SizeObject.from_degrees(2.0), # 2° description="Pleiades open cluster", aka_names=m45_aka_names, ) @@ -163,7 +163,7 @@ def add_missing_messier_objects(): ra=274.6, # 18h 18m 24s in degrees dec=-18.4, # -18° 24′ 00″ in degrees mag=MagnitudeObject([4.6]), # Visual magnitude of the brightest part - size="90'", # About 1.5 degrees + size=SizeObject.from_degrees(1.5), # ~1.5° description="Sagittarius Star Cloud", aka_names=m24_aka_names, ) @@ -183,7 +183,7 @@ def add_missing_messier_objects(): ra=226.623, # 15h 06m 29.5s in degrees dec=55.763, # +55° 45′ 48″ in degrees mag=MagnitudeObject([10.7]), - size="5.2'x2.3'", + size=SizeObject.from_arcmin(5.2, 2.3), description="Spindle Galaxy (controversial Messier object)", aka_names=m102_aka_names, ) diff --git a/python/PiFinder/catalog_imports/sac_loaders.py b/python/PiFinder/catalog_imports/sac_loaders.py index 8fa58859a..238746f55 100644 --- a/python/PiFinder/catalog_imports/sac_loaders.py +++ b/python/PiFinder/catalog_imports/sac_loaders.py @@ -9,7 +9,7 @@ from tqdm import tqdm import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( NewCatalogObject, @@ -23,6 +23,33 @@ from .database import objects_db +def _parse_sac_asterism_size(raw: str) -> SizeObject: + """Parse SAC asterism size strings like '3d X 2.4d', '10x5', '20deg x 15deg'. + + Values with 'd', 'deg', or '°' are degrees; plain numbers are arcminutes. + """ + cleaned = raw.strip().replace(" ", "").replace("X", "x") + if not cleaned: + return SizeObject([]) + parts = cleaned.split("x") + values = [] + is_degrees = False + for p in parts: + p = p.strip() + if "deg" in p or "°" in p or p.endswith("d"): + is_degrees = True + p = p.replace("deg", "").replace("°", "").rstrip("d") + try: + values.append(float(p)) + except ValueError: + return SizeObject([]) + if not values: + return SizeObject([]) + if is_degrees: + return SizeObject.from_degrees(*values) + return SizeObject.from_arcmin(*values) + + def load_sac_asterisms(): """Load the SAC Asterisms catalog""" logging.info("Loading SAC Asterisms") @@ -56,7 +83,6 @@ def load_sac_asterisms(): logging.debug( f"---------------> SAC Asterisms {sequence=} <---------------" ) - # const = dfs[2].strip() ra = dfs[3].strip() dec = dfs[4].strip() mag = dfs[5].strip() @@ -64,13 +90,7 @@ def load_sac_asterisms(): mag = MagnitudeObject([]) else: mag = MagnitudeObject([float(mag)]) - size = ( - dfs[6] - .replace(" ", "") - .replace("X", "x") - .replace("deg", "°") - .replace("d", "°") - ) + size = _parse_sac_asterism_size(dfs[6]) desc = dfs[9].strip() ra = ra.split() @@ -182,6 +202,11 @@ def load_sac_multistars(): dec_m = float(dec[1]) dec_deg = dec_to_deg(dec_d, dec_m, 0) + if sep and utils.is_number(sep): + size = SizeObject.from_arcsec(float(sep)) + else: + size = SizeObject([]) + new_object = NewCatalogObject( object_type=obj_type, catalog_code=catalog, @@ -189,7 +214,7 @@ def load_sac_multistars(): ra=ra_deg, dec=dec_deg, mag=mag, - size=sep, + size=size, description=desc, aka_names=name, ) @@ -249,10 +274,9 @@ def load_sac_redstars(): logging.debug( f"---------------> SAC Red Stars {sequence=} <---------------" ) - # const = dfs[3].strip() ra = dfs[4].strip() dec = dfs[5].strip() - size = "" + size = SizeObject([]) mag = dfs[6].strip() if mag == "none": mag = MagnitudeObject([]) diff --git a/python/PiFinder/catalog_imports/specialized_loaders.py b/python/PiFinder/catalog_imports/specialized_loaders.py index 5e29e7703..e8d68aee1 100644 --- a/python/PiFinder/catalog_imports/specialized_loaders.py +++ b/python/PiFinder/catalog_imports/specialized_loaders.py @@ -15,7 +15,7 @@ from collections import namedtuple, defaultdict import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg, b1950_to_j2000 from .catalog_import_utils import ( NewCatalogObject, @@ -23,6 +23,7 @@ insert_catalog, insert_catalog_max_sequence, add_space_after_prefix, + parse_arcmin_size, ) # Import shared database object @@ -43,7 +44,7 @@ def load_egc(): delete_catalog_from_database(catalog) insert_catalog(catalog, Path(utils.astro_data_dir, "EGC.desc")) - egc = Path(utils.astro_data_dir, "egc.tsv") + egc = Path(utils.astro_data_dir, "EGC.tsv") # Create shared ObjectFinder to avoid recreating for each object from .catalog_import_utils import ObjectFinder @@ -72,7 +73,8 @@ def load_egc(): dec_s = int(dec[2]) dec_deg = dec_to_deg(dec_deg, dec_m, dec_s) - size = dfs[5] + raw_size = dfs[5].strip() + size = parse_arcmin_size(raw_size) mag = MagnitudeObject([float(dfs[4])]) desc = dfs[7] @@ -138,7 +140,7 @@ def load_collinder(): dec_s = int(dec[9:11]) dec_deg = dec_to_deg(dec_deg, dec_m, dec_s) - size = dfs[7] + size = parse_arcmin_size(dfs[7]) desc = f"{dfs[6]} stars, like {dfs[8]}" # Assuming all the parsing logic is done and all variables are available... @@ -284,7 +286,7 @@ def load_taas200(): mag = MagnitudeObject([]) else: mag = MagnitudeObject([float(mag)]) - size = row["Size"] + size = parse_arcmin_size(row["Size"]) desc = row["Description"] nr_stars = row["# Stars"] gc = row["GC Conc or Class"] @@ -363,10 +365,13 @@ def load_rasc_double_Stars(): alternate_ids = dfs[2].split(",") wds = dfs[3] obj_type = "D*" - # const = dfs[4] mags = json.loads(dfs[7]) mag = MagnitudeObject(mags) - size = dfs[8] + raw_sep = dfs[8].strip() + try: + size = SizeObject.from_arcsec(float(raw_sep)) + except (ValueError, TypeError): + size = SizeObject([]) # 03 31.1 +27 44 ra = dfs[5].split() ra_h = int(ra[0]) @@ -429,7 +434,7 @@ def load_barnard(): for row in tqdm(list(df), leave=False): Barn = row[1:5].strip() if Barn[-1] == "a": - print(f"Skipping {Barn=}") + logging.debug(f"Skipping {Barn=}") continue RA2000h = int(row[22:24]) RA2000m = int(row[25:27]) @@ -437,7 +442,7 @@ def load_barnard(): DE2000_sign = row[32] DE2000d = int(row[33:35]) DE2000m = int(row[36:38]) - Diam = float(row[39:44]) if row[39:44].strip() else "" + raw_diam = row[39:44].strip() sequence = Barn logging.debug(f"<------------- Barnard {sequence=} ------------->") obj_type = "Nb" @@ -451,6 +456,11 @@ def load_barnard(): dec_deg = dec_to_deg(dec_deg, dec_m, 0) desc = barn_dict[Barn].strip() + if raw_diam: + barn_size = SizeObject.from_arcmin(float(raw_diam)) + else: + barn_size = SizeObject([]) + new_object = NewCatalogObject( object_type=obj_type, catalog_code=catalog, @@ -458,7 +468,7 @@ def load_barnard(): ra=ra_deg, dec=dec_deg, mag=MagnitudeObject([]), - size=str(Diam), + size=barn_size, description=desc, aka_names=[], ) @@ -490,8 +500,8 @@ def load_sharpless(): # read description dictionary descriptions_dict = {} - with open(akas, mode="r", newline="", encoding="utf-8") as file: - reader = csv.reader(open(descriptions, "r")) + with open(descriptions, "r") as file: + reader = csv.reader(file) for row in reader: if len(row) == 2: k, v = row @@ -569,7 +579,7 @@ def load_sharpless(): sequence=record["Sh2"], ra=j_ra_deg, dec=dec_deg, - size=str(record["Diam"]), + size=SizeObject.from_arcmin(float(record["Diam"])), mag=MagnitudeObject([]), description=desc, aka_names=current_akas, @@ -738,7 +748,6 @@ def load_tlk_90_vars(): ra=ra_deg, dec=dec_deg, mag=mag_object, - size="", description=desc, aka_names=current_akas, ) @@ -778,6 +787,12 @@ def load_abell(): if other_name != "": aka_names.append(other_name) + raw_abell_size = split_line[6].strip() + try: + abell_size = SizeObject.from_arcmin(float(raw_abell_size)) + except (ValueError, TypeError): + abell_size = SizeObject([]) + new_object = NewCatalogObject( object_type=obj_type, catalog_code=catalog, @@ -785,7 +800,7 @@ def load_abell(): ra=float(split_line[3].strip()), dec=float(split_line[4].strip()), mag=MagnitudeObject([float(split_line[5].strip())]), - size=split_line[6].strip(), + size=abell_size, aka_names=aka_names, ) diff --git a/python/PiFinder/catalog_imports/steinicke_loader.py b/python/PiFinder/catalog_imports/steinicke_loader.py index 6d2c31a46..acf147d92 100644 --- a/python/PiFinder/catalog_imports/steinicke_loader.py +++ b/python/PiFinder/catalog_imports/steinicke_loader.py @@ -15,8 +15,7 @@ from collections import defaultdict import PiFinder.utils as utils -from PiFinder.utils import format_size_value -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from .catalog_import_utils import ( NewCatalogObject, delete_catalog_from_database, @@ -455,12 +454,18 @@ def get_priority(obj): # Get surface brightness surface_brightness = obj.get("surface_brightness") - # Format size information - size = "" + # Format size information (arcminutes from Steinicke) + pa = float(obj["position_angle"]) if obj.get("position_angle") else 0.0 if obj.get("diameter_larger"): - size = format_size_value(obj["diameter_larger"]) + larger = float(obj["diameter_larger"]) if obj.get("diameter_smaller"): - size += f"x{format_size_value(obj['diameter_smaller'])}" + size = SizeObject.from_arcmin( + larger, float(obj["diameter_smaller"]), position_angle=pa + ) + else: + size = SizeObject.from_arcmin(larger, position_angle=pa) + else: + size = SizeObject([]) desc = "" extra = "" diff --git a/python/PiFinder/catalog_imports/wds_loader.py b/python/PiFinder/catalog_imports/wds_loader.py index 96d798630..395f35ec0 100644 --- a/python/PiFinder/catalog_imports/wds_loader.py +++ b/python/PiFinder/catalog_imports/wds_loader.py @@ -15,7 +15,7 @@ from collections import defaultdict import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( delete_catalog_from_database, @@ -206,8 +206,8 @@ def handle_multiples(key, values) -> dict: result["ra"] = value["ra"] result["dec"] = value["dec"] result["mag"] = MagnitudeObject([mag1, mag2]) - sizemax = np.max([value["Sep_First"], value["Sep_Last"]]) - result["size"] = str(round(sizemax, 1)) + sizemax = float(np.max([value["Sep_First"], value["Sep_Last"]])) + result["size"] = SizeObject.from_arcsec(round(sizemax, 1)) discoverers.add(value["Discoverer_Number"]) notes = value["Notes"].strip() notes_str = "" if len(notes) == 0 else f" Notes: {notes}" diff --git a/python/PiFinder/catalogs.py b/python/PiFinder/catalogs.py index dc2694257..da8a8e18c 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -14,7 +14,7 @@ from PiFinder.db.db import Database from PiFinder.db.objects_db import ObjectsDatabase from PiFinder.db.observations_db import ObservationsDatabase -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject from PiFinder.utils import Timer from PiFinder.config import Config from PiFinder.catalog_base import ( @@ -656,7 +656,7 @@ def add_planet(self, sequence: int, name: str, planet: Dict[str, Dict[str, float "ra": ra, "dec": dec, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": MagnitudeObject([planet["mag"]]), "names": [name.capitalize()], "catalog_code": "PL", @@ -824,7 +824,6 @@ def _create_full_composite_object(self, catalog_obj: Dict) -> CompositeObject: "sequence": catalog_obj["sequence"], "description": catalog_obj.get("description", ""), "const": obj_data.get("const", ""), - "size": obj_data.get("size", ""), "surface_brightness": obj_data.get("surface_brightness", None), } @@ -841,6 +840,8 @@ def _create_full_composite_object(self, catalog_obj: Dict) -> CompositeObject: composite_instance.mag = MagnitudeObject([]) composite_instance.mag_str = "-" + composite_instance.size = SizeObject.from_json(obj_data.get("size", "")) + composite_instance._details_loaded = True return composite_instance @@ -938,7 +939,6 @@ def _create_full_composite_object( "sequence": catalog_obj["sequence"], "description": catalog_obj.get("description", ""), "const": obj_data.get("const", ""), - "size": obj_data.get("size", ""), "surface_brightness": obj_data.get("surface_brightness", None), } @@ -955,6 +955,8 @@ def _create_full_composite_object( composite_instance.mag = MagnitudeObject([]) composite_instance.mag_str = "-" + composite_instance.size = SizeObject.from_json(obj_data.get("size", "")) + composite_instance._details_loaded = True return composite_instance diff --git a/python/PiFinder/comet_catalog.py b/python/PiFinder/comet_catalog.py index e220bf2b6..68b65bf16 100644 --- a/python/PiFinder/comet_catalog.py +++ b/python/PiFinder/comet_catalog.py @@ -13,7 +13,7 @@ ) from PiFinder.catalogs import Catalog from PiFinder.state import SharedStateObj -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject import PiFinder.comets as comets from PiFinder.utils import Timer, comet_file from PiFinder.calc_utils import sf_utils @@ -280,7 +280,7 @@ def add_comet(self, sequence: int, name: str, comet: Dict[str, Dict[str, float]] "ra": ra, "dec": dec, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": mag, "mag_str": mag.calc_two_mag_representation(), "names": [name], diff --git a/python/PiFinder/composite_object.py b/python/PiFinder/composite_object.py index 021749370..fb6251013 100644 --- a/python/PiFinder/composite_object.py +++ b/python/PiFinder/composite_object.py @@ -2,10 +2,161 @@ from dataclasses import dataclass, field import numpy as np import json -from typing import List +import math +from typing import List, Union from PiFinder.utils import is_number +class SizeObject: + """Structured angular size for astronomical objects. + + All extents are stored internally in arcseconds. + - [] -> unknown / point source + - [d] -> circular, diameter d + - [major, minor] -> elliptical (major x minor axes) + - [v1, v2, ...] -> polygon radial distances at equal angular intervals + - [[ra,dec], ...] -> RA/Dec polyline vertices (degrees) + - [[[ra,dec],[ra,dec]], ...] -> disconnected line segments (degrees) + + The geometry field disambiguates: "polyline" or "segments". + """ + + def __init__( + self, + extents: Union[List[float], List[List[float]]], + position_angle: float = 0.0, + geometry: str = "", + ): + self.extents: Union[List[float], List[List[float]]] = extents + self.position_angle: float = position_angle + self.geometry: str = geometry + + # --- mode detection --- + + @property + def is_vertices(self) -> bool: + """True for polyline vertices: [[ra,dec], ...]""" + if not self.extents: + return False + if self.geometry == "segments": + return False + if self.geometry == "polyline": + return True + return isinstance(self.extents[0], (list, tuple)) + + @property + def is_segments(self) -> bool: + """True for disconnected segments: [[[ra,dec],[ra,dec]], ...]""" + if self.geometry == "segments": + return True + return False + + def _all_vertices(self) -> List[List[float]]: + """Collect all RA/Dec vertices regardless of geometry type.""" + if self.is_segments: + verts = [] + for seg in self.extents: + verts.extend(seg) + return verts + if self.is_vertices: + return self.extents + return [] + + @property + def max_extent_arcsec(self) -> float: + if not self.extents: + return 0.0 + verts = self._all_vertices() + if verts: + max_sep = 0.0 + for i in range(len(verts)): + for j in range(i + 1, len(verts)): + ra1, dec1 = math.radians(verts[i][0]), math.radians(verts[i][1]) + ra2, dec2 = math.radians(verts[j][0]), math.radians(verts[j][1]) + dra = ra2 - ra1 + ddec = dec2 - dec1 + cos_dec = math.cos((dec1 + dec2) / 2) + sep = math.sqrt((dra * cos_dec) ** 2 + ddec**2) + max_sep = max(max_sep, sep) + return math.degrees(max_sep) * 3600.0 + return max(self.extents) + + # --- constructors --- + + @classmethod + def from_arcmin(cls, *values: float, position_angle: float = 0.0) -> "SizeObject": + return cls([v * 60.0 for v in values], position_angle=position_angle) + + @classmethod + def from_arcsec(cls, *values: float, position_angle: float = 0.0) -> "SizeObject": + return cls(list(values), position_angle=position_angle) + + @classmethod + def from_degrees(cls, *values: float, position_angle: float = 0.0) -> "SizeObject": + return cls([v * 3600.0 for v in values], position_angle=position_angle) + + @classmethod + def from_vertices(cls, vertices: List[List[float]]) -> "SizeObject": + return cls(vertices, position_angle=0.0) + + # --- serialization --- + + def to_json(self) -> str: + return json.dumps({"e": self.extents, "p": self.position_angle}) + + @classmethod + def from_json(cls, json_str: str) -> "SizeObject": + if not json_str: + return cls([]) + parsed = json.loads(json_str) + return cls(parsed["e"], position_angle=parsed.get("p", 0.0)) + + # --- display --- + + def _format_value(self, arcsec: float, unit_suffix: str) -> str: + """Format a single value, dropping .0 for whole numbers.""" + if unit_suffix == '"': + val = arcsec + elif unit_suffix == "'": + val = arcsec / 60.0 + else: + val = arcsec / 3600.0 + if val == int(val): + return f"{int(val)}{unit_suffix}" + return f"{val:.1f}{unit_suffix}" + + def _pick_unit(self, arcsec: float) -> str: + """Choose display unit for a value in arcseconds.""" + if arcsec >= 3600.0: + return "°" + if arcsec >= 60.0: + return "'" + return '"' + + def to_display_string(self) -> str: + if not self.extents: + return "" + if self.is_vertices or self.is_segments: + extent = self.max_extent_arcsec + return f"~{self._format_value(extent, self._pick_unit(extent))}" + unit = self._pick_unit(max(self.extents)) + if len(self.extents) == 1: + return self._format_value(self.extents[0], unit) + if len(self.extents) == 2: + a = self._format_value(self.extents[0], unit) + b = self._format_value(self.extents[1], unit) + # strip repeated unit suffix for compact display: 17'x8' + return f"{a}x{b}" + # 3+ extents: show max extent only with polygon marker + return f"~{self._format_value(max(self.extents), unit)}" + + def __repr__(self) -> str: + return f"SizeObject({self.extents})" + + def __str__(self) -> str: + return self.to_display_string() + + class MagnitudeObject: UNKNOWN_MAG: float = 99 mags: List = [] @@ -67,7 +218,7 @@ class CompositeObject: # dec in degrees, J2000 dec: float = field(default=0.0) const: str = field(default="") - size: str = field(default="") + size: "SizeObject" = field(default_factory=lambda: SizeObject([])) mag: MagnitudeObject = field(default=MagnitudeObject([])) mag_str: str = field(default="") catalog_code: str = field(default="") diff --git a/python/PiFinder/db/objects_db.py b/python/PiFinder/db/objects_db.py index b52057f41..3e77ea918 100644 --- a/python/PiFinder/db/objects_db.py +++ b/python/PiFinder/db/objects_db.py @@ -18,9 +18,6 @@ def __init__(self, db_path=utils.pifinder_db): self.cursor.execute("PRAGMA mmap_size = 268435456;") # 256MB memory mapping self.cursor.execute("PRAGMA cache_size = -64000;") # 64MB cache (negative = KB) self.cursor.execute("PRAGMA temp_store = MEMORY;") # Keep temporary data in RAM - self.cursor.execute( - "PRAGMA journal_mode = WAL;" - ) # Write-ahead logging for better concurrency self.cursor.execute( "PRAGMA synchronous = NORMAL;" ) # Balanced safety/performance @@ -40,7 +37,7 @@ def create_tables(self): dec NUMERIC, const TEXT, size TEXT, - mag NUMERIC, + mag TEXT, surface_brightness NUMERIC ); """ diff --git a/python/PiFinder/gen_images.py b/python/PiFinder/gen_images.py index 81865cb4d..9a2c5ef7d 100644 --- a/python/PiFinder/gen_images.py +++ b/python/PiFinder/gen_images.py @@ -11,6 +11,7 @@ from PIL import Image, ImageOps from PiFinder.db.objects_db import ObjectsDatabase from PiFinder.catalogs import CompositeObject +from PiFinder.composite_object import SizeObject, MagnitudeObject BASE_IMAGE_PATH = "/Users/rich/Projects/Astronomy/PiFinder/astro_data/catalog_images" @@ -56,7 +57,10 @@ def fetch_object_image(_obj, low_cut=10): Returns image path """ - catalog_object = CompositeObject.from_dict(dict(_obj)) + obj_dict = dict(_obj) + obj_dict["size"] = SizeObject.from_json(obj_dict.get("size", "")) + obj_dict["mag"] = MagnitudeObject.from_json(obj_dict.get("mag", "")) + catalog_object = CompositeObject.from_dict(obj_dict) ra = catalog_object.ra dec = catalog_object.dec diff --git a/python/PiFinder/pos_server.py b/python/PiFinder/pos_server.py index cfc523c34..ea590e943 100644 --- a/python/PiFinder/pos_server.py +++ b/python/PiFinder/pos_server.py @@ -16,7 +16,7 @@ from multiprocessing import Queue from typing import Tuple, Union from PiFinder.calc_utils import ra_to_deg, dec_to_deg, sf_utils -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject from PiFinder.multiproclogging import MultiprocLogging from skyfield.positionlib import position_of_radec import sys @@ -190,7 +190,7 @@ def handle_goto_command(shared_state, ra_parsed, dec_parsed): "ra": comp_ra, "dec": comp_dec, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": MagnitudeObject([]), "catalog_code": "PUSH", "sequence": sequence, diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 78878c178..cbe1c7030 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -16,7 +16,7 @@ from PiFinder import utils, calc_utils from PiFinder.ui.base import UIModule from PiFinder.catalogs import CatalogFilter -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject if TYPE_CHECKING: @@ -345,7 +345,7 @@ def create_custom_object_from_coords( "ra": ra_deg, "dec": dec_deg, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": MagnitudeObject([]), "mag_str": "", "catalog_code": "USER", diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index e4ee44380..26989aa85 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -854,6 +854,45 @@ def _(key: str) -> Any: }, ], }, + { + "name": _("Image..."), + "class": UITextMenu, + "select": "single", + "items": [ + { + "name": _("NSEW Labels"), + "class": UITextMenu, + "select": "single", + "config_option": "image_nsew", + "items": [ + { + "name": _("On"), + "value": True, + }, + { + "name": _("Off"), + "value": False, + }, + ], + }, + { + "name": _("Object Size"), + "class": UITextMenu, + "select": "single", + "config_option": "image_bbox", + "items": [ + { + "name": _("On"), + "value": True, + }, + { + "name": _("Off"), + "value": False, + }, + ], + }, + ], + }, { "name": _("Camera Exp"), "class": UITextMenu, diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index edf13f9d7..90d8e0b2f 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -226,6 +226,9 @@ def update_object_info(self): self.display_class, burn_in=self.object_display_mode in [DM_POSS, DM_SDSS], magnification=magnification, + telescope=self.config_object.equipment.active_telescope, + show_nsew=self.config_object.get_option("image_nsew", True), + show_bbox=self.config_object.get_option("image_bbox", True), ) def active(self): diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index d525e1f90..26380ab2a 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -382,7 +382,8 @@ def create_aka_text(self, obj: CompositeObject) -> str: def create_info_text(self, obj: CompositeObject) -> str: obj_mag = self._safe_obj_mag(obj) mag = f"m{obj_mag:2.0f}" if obj_mag != MagnitudeObject.UNKNOWN_MAG else "m--" - size = f"{self.ruler}{obj.size.strip()}" if obj.size.strip() else "" + size_str = str(obj.size) + size = f"{self.ruler}{size_str}" if size_str else "" check = f" {self.checkmark}" if obj.logged else "" size_logged = f"{mag} {size}{check}" if len(size_logged) > 12: diff --git a/python/PiFinder/utils.py b/python/PiFinder/utils.py index 523228537..4d130d985 100644 --- a/python/PiFinder/utils.py +++ b/python/PiFinder/utils.py @@ -127,25 +127,3 @@ def is_number(s): return False -def format_size_value(value): - """ - Format a size value, removing unnecessary .0 decimals but preserving meaningful decimals. - - Examples: - 17.0 -> "17" - 17.5 -> "17.5" - 17.25 -> "17.3" (rounded to 1 decimal) - """ - if value is None or value == "": - return "" - - try: - num_val = float(value) - # If it's a whole number, return as integer - if num_val == int(num_val): - return str(int(num_val)) - # Otherwise, round to 1 decimal and remove trailing zeros - formatted = f"{num_val:.1f}" - return formatted.rstrip("0").rstrip(".") - except (ValueError, TypeError): - return str(value) # Return as-is if not a number diff --git a/python/tests/test_cat_images.py b/python/tests/test_cat_images.py new file mode 100644 index 000000000..d31edec39 --- /dev/null +++ b/python/tests/test_cat_images.py @@ -0,0 +1,186 @@ +import math +import pytest +from PiFinder.cat_images import cardinal_vectors, size_overlay_points + + +def approx_pt(pt, abs=1e-6): + return pytest.approx(pt, abs=abs) + + +# --- cardinal_vectors --- + + +@pytest.mark.unit +class TestCardinalVectors: + def test_no_rotation(self): + """image_rotate=0: POSS north-up, east-left → N at (0, -1), E at (-1, 0).""" + (nx, ny), (ex, ey) = cardinal_vectors(0) + assert (nx, ny) == approx_pt((0, -1)) + assert (ex, ey) == approx_pt((-1, 0)) + + def test_180_rotation(self): + """image_rotate=180: N flips to (0, 1), E to (1, 0).""" + (nx, ny), (ex, ey) = cardinal_vectors(180) + assert (nx, ny) == approx_pt((0, 1)) + assert (ex, ey) == approx_pt((1, 0)) + + def test_90_rotation(self): + """image_rotate=90: N at (1, 0), E at (0, -1).""" + (nx, ny), (ex, ey) = cardinal_vectors(90) + assert (nx, ny) == approx_pt((1, 0)) + assert (ex, ey) == approx_pt((0, -1)) + + def test_flip_mirrors_x(self): + """flip negates x components of both vectors.""" + (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1) + assert (nx, ny) == approx_pt((0, -1)) + assert (ex, ey) == approx_pt((1, 0)) + + def test_flop_mirrors_y(self): + """flop negates y components of both vectors.""" + (nx, ny), (ex, ey) = cardinal_vectors(0, fy=-1) + assert (nx, ny) == approx_pt((0, 1)) + assert (ex, ey) == approx_pt((-1, 0)) + + def test_flip_and_flop(self): + """Both flip and flop: equivalent to 180° rotation of vectors.""" + (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1, fy=-1) + assert (nx, ny) == approx_pt((0, 1)) + assert (ex, ey) == approx_pt((1, 0)) + + def test_orthogonality(self): + """N and E should always be perpendicular.""" + for angle in [0, 45, 90, 135, 180, 270]: + for fx, fy in [(1, 1), (-1, 1), (1, -1), (-1, -1)]: + (nx, ny), (ex, ey) = cardinal_vectors(angle, fx, fy) + dot = nx * ex + ny * ey + assert dot == pytest.approx(0, abs=1e-10), ( + f"Not orthogonal at angle={angle}, fx={fx}, fy={fy}" + ) + + def test_unit_length(self): + """N and E vectors should have unit length.""" + for angle in [0, 30, 45, 90, 180, 270]: + (nx, ny), (ex, ey) = cardinal_vectors(angle) + assert math.hypot(nx, ny) == pytest.approx(1) + assert math.hypot(ex, ey) == pytest.approx(1) + + +# --- size_overlay_points --- + + +@pytest.mark.unit +class TestSizeOverlayPoints: + def test_single_extent_returns_none(self): + """1 extent → None (caller uses native ellipse).""" + assert size_overlay_points([100], 0, 0, 1.0, 64, 64) is None + + def test_empty_returns_none(self): + assert size_overlay_points([], 0, 0, 1.0, 64, 64) is None + + def test_two_extents_point_count(self): + """2 extents → 36-point ellipse polygon.""" + pts = size_overlay_points([120, 60], 0, 0, 1.0, 64, 64) + assert len(pts) == 36 + + def test_two_extents_centered(self): + """Ellipse centroid should be at (cx, cy).""" + cx, cy = 64, 64 + pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) + avg_x = sum(p[0] for p in pts) / len(pts) + avg_y = sum(p[1] for p in pts) / len(pts) + assert avg_x == pytest.approx(cx, abs=0.1) + assert avg_y == pytest.approx(cy, abs=0.1) + + def test_two_extents_symmetry(self): + """No rotation, no PA: major axis aligned with North (vertical).""" + cx, cy = 64, 64 + pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) + xs = [p[0] - cx for p in pts] + ys = [p[1] - cy for p in pts] + # PA=0 → major axis along North → vertical + assert max(abs(x) for x in xs) == pytest.approx(30, abs=0.5) + assert max(abs(y) for y in ys) == pytest.approx(60, abs=0.5) + + def test_two_extents_rotation(self): + """90° image rotation moves major axis from vertical to horizontal.""" + cx, cy = 64, 64 + pts = size_overlay_points([120, 60], 0, 90, 1.0, cx, cy) + xs = [p[0] - cx for p in pts] + ys = [p[1] - cy for p in pts] + # 90° rotation: North moves to +X, major axis now horizontal + assert max(abs(x) for x in xs) == pytest.approx(60, abs=0.5) + assert max(abs(y) for y in ys) == pytest.approx(30, abs=0.5) + + def test_position_angle(self): + """PA=90 rotates opposite to image_rotate (PA goes N→E, image_rotate goes CW).""" + cx, cy = 64, 64 + pts_rot = size_overlay_points([120, 60], 0, 270, 1.0, cx, cy) + pts_pa = size_overlay_points([120, 60], 90, 0, 1.0, cx, cy) + for a, b in zip(pts_rot, pts_pa): + assert a[0] == pytest.approx(b[0], abs=1e-6) + assert a[1] == pytest.approx(b[1], abs=1e-6) + + def test_pa90_aligns_with_east(self): + """PA=90° major axis must align with the East vector from cardinal_vectors.""" + cx, cy = 64, 64 + for rot in [0, 90, 180, 270]: + _, (ex, ey) = cardinal_vectors(rot) + pts = size_overlay_points([200, 40], 90, rot, 1.0, cx, cy) + dists = [(p[0] - cx, p[1] - cy) for p in pts] + farthest = max(dists, key=lambda d: math.hypot(*d)) + direction = (farthest[0] / math.hypot(*farthest), + farthest[1] / math.hypot(*farthest)) + dot = abs(direction[0] * ex + direction[1] * ey) + assert dot == pytest.approx(1.0, abs=0.02), ( + f"PA=90 major axis not along East at image_rotate={rot}" + ) + + def test_pa0_aligns_with_north(self): + """PA=0 major axis must align with the North vector from cardinal_vectors.""" + cx, cy = 64, 64 + for rot in [0, 90, 180, 270]: + (nx, ny), _ = cardinal_vectors(rot) + pts = size_overlay_points([200, 40], 0, rot, 1.0, cx, cy) + # Find the point farthest from center — should be along North + dists = [(p[0] - cx, p[1] - cy) for p in pts] + farthest = max(dists, key=lambda d: math.hypot(*d)) + direction = (farthest[0] / math.hypot(*farthest), + farthest[1] / math.hypot(*farthest)) + # Should be parallel to North (same or opposite direction) + dot = abs(direction[0] * nx + direction[1] * ny) + assert dot == pytest.approx(1.0, abs=0.02), ( + f"PA=0 major axis not along North at image_rotate={rot}" + ) + + def test_flip_mirrors_x(self): + """fx=-1 mirrors all points horizontally around cx.""" + cx, cy = 64, 64 + pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) + pts_flip = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fx=-1) + for a, b in zip(pts_normal, pts_flip): + assert a[0] - cx == pytest.approx(-(b[0] - cx), abs=1e-6) + assert a[1] == pytest.approx(b[1], abs=1e-6) + + def test_flop_mirrors_y(self): + """fy=-1 mirrors all points vertically around cy.""" + cx, cy = 64, 64 + pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) + pts_flop = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fy=-1) + for a, b in zip(pts_normal, pts_flop): + assert a[0] == pytest.approx(b[0], abs=1e-6) + assert a[1] - cy == pytest.approx(-(b[1] - cy), abs=1e-6) + + def test_three_extents_point_count(self): + """3+ extents → polygon with len(extents) points.""" + pts = size_overlay_points([100, 80, 60, 90], 0, 0, 1.0, 64, 64) + assert len(pts) == 4 + + def test_px_per_arcsec_scaling(self): + """Doubling px_per_arcsec doubles the distance from center.""" + cx, cy = 64, 64 + pts1 = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) + pts2 = size_overlay_points([120, 60], 0, 0, 2.0, cx, cy) + for a, b in zip(pts1, pts2): + assert (b[0] - cx) == pytest.approx(2 * (a[0] - cx), abs=1e-6) + assert (b[1] - cy) == pytest.approx(2 * (a[1] - cy), abs=1e-6)