Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ae318f5
feat: introduce SizeObject for structured angular sizes
mrosseel Mar 5, 2026
bfa690a
fix: simplify SizeObject JSON to plain array, fix EGC.tsv case
mrosseel Mar 5, 2026
998221c
feat: add size overlay, NSEW labels, and position angle to catalog im…
mrosseel Mar 6, 2026
c0d78d7
refactor: extract overlay math into testable helpers with unit tests
mrosseel Mar 6, 2026
3b646b1
refactor: deduplicate size parsing, remove dead code, compact seriali…
mrosseel Mar 8, 2026
059ac00
fix: rebuild DB with compatible SQLite version
mrosseel Mar 8, 2026
d123540
fix: remove WAL mode from runtime, finalize DB at import
mrosseel Mar 9, 2026
49834ce
fix: correct 90° offset in size overlay ellipse rotation
mrosseel Mar 9, 2026
780bdde
fix: correct East vector sign and PA direction in overlay
mrosseel Mar 9, 2026
3aca818
fix: show only 2 cardinal labels, fix file leak and cleanup
mrosseel Mar 10, 2026
a4d5ce2
feat: extend SizeObject with vertex/segment geometry support
mrosseel Mar 18, 2026
d2131ab
Merge upstream/main into sizes
mrosseel Apr 29, 2026
6c1d07a
feat: render SizeObject vertex polylines for asterisms
mrosseel Apr 29, 2026
02db1dd
fix: type-narrow SizeObject Union[List[float], List[List[float]]] paths
mrosseel Apr 29, 2026
38d4da0
fix: convert object_details size handling to SizeObject API
mrosseel Apr 29, 2026
2897e79
fix: handle empty/non-numeric mag_str in object_details
mrosseel Apr 29, 2026
4d41661
fix: use MagnitudeObject.filter_mag for contrast calculation
mrosseel Apr 29, 2026
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
2 changes: 2 additions & 0 deletions default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
193 changes: 193 additions & 0 deletions python/PiFinder/cat_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
to handle catalog image loading
"""

import math
import os
from PIL import Image, ImageChops, ImageDraw
from PiFinder import image_util
Expand All @@ -19,6 +20,103 @@
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 vertex_overlay_points(
vertices, obj_ra, obj_dec, image_rotate, px_per_arcsec, cx, cy, fx=1, fy=1
):
"""Project RA/Dec vertex pairs to pixel coords via gnomonic projection.

vertices: list of [ra, dec] pairs in degrees.
obj_ra, obj_dec: object center in degrees.
Returns list of (x, y) pixel tuples.
"""
theta = math.radians(image_rotate)
cos_t = math.cos(theta)
sin_t = math.sin(theta)

ra0 = math.radians(obj_ra)
dec0 = math.radians(obj_dec)
cos_dec0 = math.cos(dec0)
sin_dec0 = math.sin(dec0)

points = []
for ra_deg, dec_deg in vertices:
ra = math.radians(ra_deg)
dec = math.radians(dec_deg)
cos_dec = math.cos(dec)
sin_dec = math.sin(dec)
dra = ra - ra0

cos_c = sin_dec0 * sin_dec + cos_dec0 * cos_dec * math.cos(dra)
if cos_c <= 0:
continue
# gnomonic: xi points East, eta points North (radians)
xi = (cos_dec * math.sin(dra)) / cos_c
eta = (cos_dec0 * sin_dec - sin_dec0 * cos_dec * math.cos(dra)) / cos_c

# convert to arcsec offsets then pixels
dx_arcsec = -xi * 206264.806 # negate: East is left on POSS
dy_arcsec = -eta * 206264.806 # negate: North is up, pixel y is down

dx_px = dx_arcsec * px_per_arcsec
dy_px = dy_arcsec * px_per_arcsec

# apply image rotation
rx = dx_px * cos_t - dy_px * sin_t
ry = dx_px * sin_t + dy_px * cos_t

points.append((cx + fx * rx, cy + fy * ry))
return points


def get_display_image(
catalog_object,
eyepiece_text,
Expand All @@ -27,6 +125,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
Expand All @@ -37,6 +138,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)
Expand All @@ -59,6 +162,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)
Expand Down Expand Up @@ -98,6 +205,92 @@ 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 catalog_object.size.is_vertices:
points = vertex_overlay_points(
extents,
catalog_object.ra,
catalog_object.dec,
image_rotate,
px_per_arcsec,
cx,
cy,
fx,
fy,
)
if len(points) >= 2:
ri_draw.line(points, fill=overlay_color, width=1)
elif 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)
Expand Down
6 changes: 2 additions & 4 deletions python/PiFinder/catalog_imports/bright_stars_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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])
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion python/PiFinder/catalog_imports/caldwell_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
insert_catalog,
insert_catalog_max_sequence,
add_space_after_prefix,
parse_arcmin_size,
)

# Import shared database object
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 19 additions & 3 deletions python/PiFinder/catalog_imports/catalog_import_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 4 additions & 6 deletions python/PiFinder/catalog_imports/harris_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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})")

Expand Down
8 changes: 6 additions & 2 deletions python/PiFinder/catalog_imports/herschel_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading