Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
ca4350a
Merge upstream/main into sizes
mrosseel May 24, 2026
ae3fd9b
Merge remote-tracking branch 'upstream/main' into sizes
mrosseel Jun 10, 2026
f4018bc
Regenerate objects database after upstream merge
mrosseel Jun 11, 2026
68375a6
fix(catalog): parse unit-suffixed/separated size tokens; add coverage…
mrosseel Jun 11, 2026
3fca5af
Regenerate objects database with fixed size parser
mrosseel Jun 11, 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
Binary file modified astro_data/pifinder_objects.db
Binary file not shown.
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,
"chart_coord_sys": "horiz",
"target_pixel": [256, 256],
"gps_type": "ublox",
Expand Down
191 changes: 190 additions & 1 deletion 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 _orient_image(return_image, roll, flip_image, flop_image):
"""
Orient a source survey image to match the eyepiece view.
Expand Down Expand Up @@ -54,6 +152,8 @@ def get_display_image(
display_class,
burn_in=True,
magnification=None,
show_nsew=True,
show_bbox=True,
flip_image=False,
flop_image=False,
):
Expand All @@ -66,7 +166,6 @@ def get_display_image(
roll:
degrees
"""

object_image_path = resolve_image_name(catalog_object, source="POSS")
logger.debug("object_image_path = %s", object_image_path)
if not os.path.exists(object_image_path):
Expand All @@ -82,6 +181,10 @@ def get_display_image(
else:
return_image = Image.open(object_image_path)

image_rotate = 180
if roll is not None:
image_rotate += roll

# Orient to match the eyepiece view (see ADR 0003)
return_image = _orient_image(return_image, roll, flip_image, flop_image)

Expand Down Expand Up @@ -123,6 +226,92 @@ def get_display_image(
width=1,
)

cx = display_class.fov_res / 2
cy = display_class.fov_res / 2
fx = -1 if flop_image else 1
fy = -1 if flip_image 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
Loading
Loading