From b2a8da43753195843a7503f17196145529f97319 Mon Sep 17 00:00:00 2001 From: GlassOnTin Date: Tue, 18 Aug 2026 02:34:56 +0100 Subject: [PATCH 1/3] feat(astrometry): optional position_hint for astrometry.net solves autoFitPlatepar/astrometryNetSolve always did a blind all-sky solve -- position_hint was hardcoded to None -- even when the caller knew roughly where the camera points. For narrow-FOV or faint cameras (e.g. a mono global-shutter sensor behind a longer lens), a blind solve needs far more stars than such a field yields, so it fails. Thread an optional position_hint=(ra_deg, dec_deg, radius_deg) through autoFitPlatepar -> astrometryNetSolve -> astrometryNetSolveLocal to the astrometry.net Solver. When given, it collapses the all-sky search to a small patch and the solve converges with far fewer stars. Defaults to None everywhere, so the blind behaviour is unchanged for every existing caller. Verified on an IMX296 mono / 4 mm f/0.95 station: the blind solve needed ~100 stars to lock; with a zenith hint (RA=LST, Dec=lat) the same field solves at ~28 stars. Co-Authored-By: Claude Opus 4.8 --- RMS/Astrometry/AstrometryNet.py | 29 ++++++++++++++++++++++++----- RMS/Astrometry/AutoPlatepar.py | 5 +++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/RMS/Astrometry/AstrometryNet.py b/RMS/Astrometry/AstrometryNet.py index 921051303..260461515 100644 --- a/RMS/Astrometry/AstrometryNet.py +++ b/RMS/Astrometry/AstrometryNet.py @@ -32,7 +32,7 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, y_data=None, fov_w_range=None, fov_w_hint=None, max_stars=100, verbose=False, x_center=None, y_center=None, - lat=None, lon=None, jd=None, input_intensities=None): + lat=None, lon=None, jd=None, input_intensities=None, position_hint=None): """ Find an astrometric solution of X, Y image coordinates of stars detected on an image using the local installation of astrometry.net. @@ -52,6 +52,9 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, lon: [float] Station longitude in degrees. Required for iterative matching. jd: [float] Julian date. Required for iterative matching. input_intensities: [ndarray] Star intensities for brightness-based matching. Optional. + position_hint: [tuple] Optional (ra_deg, dec_deg, radius_deg). When given, astrometry.net is + told roughly where the field is, collapsing the blind all-sky search to that patch -- + which lets narrow-FOV / faint cameras solve with far fewer stars. None = blind (default). Returns: [tuple] A tuple containing the following elements: @@ -324,6 +327,19 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, ) ) + # Optional position hint: when the caller knows roughly where the camera points (e.g. a + # previous platepar, or a known station pointing), this collapses the blind all-sky search + # to a small patch, letting narrow-FOV / faint cameras solve with far fewer stars than a + # blind solve needs. None (the default) preserves the original blind behaviour. + position_hint_obj = None + if position_hint is not None: + ra_hint, dec_hint, radius_hint = position_hint + position_hint_obj = astrometry.PositionHint( + ra_deg=float(ra_hint), dec_deg=float(dec_hint), radius_deg=float(radius_hint)) + if verbose: + print("Using position hint: RA={:.2f} Dec={:.2f} radius={:.1f} deg".format( + ra_hint, dec_hint, radius_hint)) + # If the solver.solve has the argument "stars", use a 2D array of stars instead of stars_xs and stars_ys solve_args = inspect.getfullargspec(solver.solve).args if "stars" in solve_args: @@ -332,7 +348,7 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, solution = solver.solve( stars=star_data, size_hint=size_hint, - position_hint=None, + position_hint=position_hint_obj, solution_parameters=solution_parameters ) @@ -342,7 +358,7 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, stars_xs=x_data, stars_ys=y_data, size_hint=size_hint, - position_hint=None, + position_hint=position_hint_obj, solution_parameters=solution_parameters ) @@ -451,7 +467,7 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, def astrometryNetSolve(ff_file_path=None, img=None, mask=None, x_data=None, y_data=None, fov_w_range=None, fov_w_hint=None, max_stars=100, verbose=False, x_center=None, y_center=None, - lat=None, lon=None, jd=None, input_intensities=None): + lat=None, lon=None, jd=None, input_intensities=None, position_hint=None): """ Find an astrometric solution of X, Y image coordinates of stars detected on an image using the local installation of astrometry.net. @@ -471,6 +487,9 @@ def astrometryNetSolve(ff_file_path=None, img=None, mask=None, x_data=None, y_da lon: [float] Station longitude in degrees. Required for iterative matching. jd: [float] Julian date. Required for iterative matching. input_intensities: [ndarray] Star intensities for brightness-based matching. Optional. + position_hint: [tuple] Optional (ra_deg, dec_deg, radius_deg). When given, astrometry.net is + told roughly where the field is, collapsing the blind all-sky search to that patch -- + which lets narrow-FOV / faint cameras solve with far fewer stars. None = blind (default). """ # Helper to try coordinate-only first, then fall back to image if available @@ -540,7 +559,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce ff_file_path=ff_file_path, img=img, mask=mask, x_data=x_data, y_data=y_data, fov_w_range=fov_w_range, fov_w_hint=fov_w_hint, max_stars=max_stars, verbose=verbose, x_center=x_center, y_center=y_center, - lat=lat, lon=lon, jd=jd, input_intensities=input_intensities + lat=lat, lon=lon, jd=jd, input_intensities=input_intensities, position_hint=position_hint ) # If local fails, try remote APIs diff --git a/RMS/Astrometry/AutoPlatepar.py b/RMS/Astrometry/AutoPlatepar.py index 4a5391a43..3a1e48d58 100644 --- a/RMS/Astrometry/AutoPlatepar.py +++ b/RMS/Astrometry/AutoPlatepar.py @@ -329,6 +329,7 @@ def autoFitPlatepar(dir_path, config, catalog_stars, platepar_template=None, photometric_sigma=DEFAULT_PHOTOMETRIC_SIGMA, fwhm_mult=DEFAULT_BLEND_FWHM_MULT, wide_fov_search=False, + position_hint=None, final_catalog_stars=None, verbose=True): """ @@ -366,6 +367,9 @@ def autoFitPlatepar(dir_path, config, catalog_stars, platepar_template=None, wide_fov_search: [bool] If True, use a wide FOV search range (2° to 200°) instead of the config-based range. Used as fallback when the tight search fails. + position_hint: [tuple] Optional (ra_deg, dec_deg, radius_deg) forwarded to astrometry.net + as a search-position hint. Lets star-starved / narrow-FOV cameras solve + with fewer stars. None = blind all-sky search (default). verbose: [bool] Print progress information Returns: @@ -533,6 +537,7 @@ def autoFitPlatepar(dir_path, config, catalog_stars, platepar_template=None, lon=platepar.lon, jd=jd, input_intensities=input_intensities, + position_hint=position_hint, verbose=verbose ) From 1d54f4eedaa3b0d39f741dad585c919d54f4ae05 Mon Sep 17 00:00:00 2001 From: Denis Vida Date: Mon, 24 Aug 2026 14:30:18 -0400 Subject: [PATCH 2/3] Fix position hint propagation and add coverage --- RMS/Astrometry/AstrometryNet.py | 21 +-- RMS/Astrometry/AstrometryNetNova.py | 12 +- RMS/Astrometry/AutoPlatepar.py | 8 +- Tests/TestAstrometryNet.py | 245 ++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 Tests/TestAstrometryNet.py diff --git a/RMS/Astrometry/AstrometryNet.py b/RMS/Astrometry/AstrometryNet.py index 260461515..a9cd41ae0 100644 --- a/RMS/Astrometry/AstrometryNet.py +++ b/RMS/Astrometry/AstrometryNet.py @@ -469,7 +469,7 @@ def astrometryNetSolve(ff_file_path=None, img=None, mask=None, x_data=None, y_da fov_w_hint=None, max_stars=100, verbose=False, x_center=None, y_center=None, lat=None, lon=None, jd=None, input_intensities=None, position_hint=None): """ Find an astrometric solution of X, Y image coordinates of stars detected on an image using the - local installation of astrometry.net. + local installation of astrometry.net or a compatible remote service. Keyword arguments: ff_file_path: [str] Path to the FF file to load. @@ -493,7 +493,8 @@ def astrometryNetSolve(ff_file_path=None, img=None, mask=None, x_data=None, y_da """ # Helper to try coordinate-only first, then fall back to image if available - def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_cen, y_cen): + def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_cen, y_cen, + position_hint): """Try coordinate-only solve first, fall back to image if that fails.""" # If we have coordinates, try coordinate-only first (faster, less bandwidth) @@ -503,7 +504,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce result = novaAstrometryNetSolve( ff_file_path=None, img=None, x_data=x_coords, y_data=y_coords, fov_w_range=fov_range, x_center=x_cen, y_center=y_cen, - api_url=api_url + api_url=api_url, position_hint=position_hint ) if result is not None: return result @@ -517,7 +518,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce return novaAstrometryNetSolve( ff_file_path=ff_path, img=image, x_data=None, y_data=None, fov_w_range=fov_range, x_center=x_cen, y_center=y_cen, - api_url=api_url + api_url=api_url, position_hint=position_hint ) return None @@ -525,7 +526,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce return novaAstrometryNetSolve( ff_file_path=ff_path, img=image, x_data=x_coords, y_data=y_coords, fov_w_range=fov_range, x_center=x_cen, y_center=y_cen, - api_url=api_url + api_url=api_url, position_hint=position_hint ) # If the local installation of astrometry.net is not available, use remote API @@ -537,7 +538,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce try: result = _tryRemoteSolve( PRIMARY_API_URL, ff_file_path, img, x_data, y_data, - fov_w_range, x_center, y_center + fov_w_range, x_center, y_center, position_hint ) if result is not None: return result @@ -548,7 +549,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce print(f"Trying fallback server: {FALLBACK_API_URL}") return _tryRemoteSolve( FALLBACK_API_URL, ff_file_path, img, x_data, y_data, - fov_w_range, x_center, y_center + fov_w_range, x_center, y_center, position_hint ) else: @@ -573,7 +574,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce try: result = _tryRemoteSolve( PRIMARY_API_URL, ff_file_path, img, x_data, y_data, - fov_w_range, x_center, y_center + fov_w_range, x_center, y_center, position_hint ) if result is not None: return result @@ -584,7 +585,7 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce print(f"Trying fallback server: {FALLBACK_API_URL}") return _tryRemoteSolve( FALLBACK_API_URL, ff_file_path, img, x_data, y_data, - fov_w_range, x_center, y_center + fov_w_range, x_center, y_center, position_hint ) @@ -647,4 +648,4 @@ def _tryRemoteSolve(api_url, ff_path, image, x_coords, y_coords, fov_range, x_ce star['ra_deg'], star['dec_deg'], star['x_pix'], star['y_pix'])) else: - print("No solution found.") \ No newline at end of file + print("No solution found.") diff --git a/RMS/Astrometry/AstrometryNetNova.py b/RMS/Astrometry/AstrometryNetNova.py index 05582c15f..3108afbc4 100644 --- a/RMS/Astrometry/AstrometryNetNova.py +++ b/RMS/Astrometry/AstrometryNetNova.py @@ -282,7 +282,7 @@ def jobs_by_tag(self, tag, exact): def novaAstrometryNetSolve(ff_file_path=None, img=None, x_data=None, y_data=None, fov_w_range=None, - api_key=None, x_center=None, y_center=None, api_url=None): + api_key=None, x_center=None, y_center=None, api_url=None, position_hint=None): """ Find an astrometric solution of X, Y image coordinates of stars detected on an image using the nova.astrometry.net service or a compatible API. @@ -299,6 +299,7 @@ def novaAstrometryNetSolve(ff_file_path=None, img=None, x_data=None, y_data=None y_center: [float] Y coordinate of the image center. If not given, the image center will be used. api_url: [str] Custom API URL. None by default, in which case nova.astrometry.net will be used. Can be set to use alternative servers like 'https://astro.contrailcast.com/api/'. + position_hint: [tuple] Optional (ra_deg, dec_deg, radius_deg) search-position hint. Return: (ra, dec, orientation, scale, fov_w, fov_h, star_data): [tuple of floats] All in degrees, @@ -390,6 +391,13 @@ def _printWebLink(stat, first_status=None): kwargs['scale_upper'] = scale_upper kwargs['scale_units'] = 'degwidth' # FOV range is in degrees + # Restrict the remote all-sky search when an approximate field position is known + if position_hint is not None: + ra_hint, dec_hint, radius_hint = position_hint + kwargs['center_ra'] = float(ra_hint) + kwargs['center_dec'] = float(dec_hint) + kwargs['radius'] = float(radius_hint) + # Upload image or the list of stars if file_handle is not None: @@ -701,4 +709,4 @@ def _printWebLink(stat, first_status=None): # 'radius': 29.77070194214109, # 'dec': 75.03531583306331, # 'height_arcsec': 105022.27049595825, - # 'orientation': -75.0809053003417} \ No newline at end of file + # 'orientation': -75.0809053003417} diff --git a/RMS/Astrometry/AutoPlatepar.py b/RMS/Astrometry/AutoPlatepar.py index 3a1e48d58..92999ebdc 100644 --- a/RMS/Astrometry/AutoPlatepar.py +++ b/RMS/Astrometry/AutoPlatepar.py @@ -329,9 +329,9 @@ def autoFitPlatepar(dir_path, config, catalog_stars, platepar_template=None, photometric_sigma=DEFAULT_PHOTOMETRIC_SIGMA, fwhm_mult=DEFAULT_BLEND_FWHM_MULT, wide_fov_search=False, - position_hint=None, final_catalog_stars=None, - verbose=True): + verbose=True, + position_hint=None): """ Automatically create a platepar from CALSTARS data in a directory. @@ -367,10 +367,11 @@ def autoFitPlatepar(dir_path, config, catalog_stars, platepar_template=None, wide_fov_search: [bool] If True, use a wide FOV search range (2° to 200°) instead of the config-based range. Used as fallback when the tight search fails. + final_catalog_stars: [ndarray] Optional catalog used for the final astrometric fit. + verbose: [bool] Print progress information position_hint: [tuple] Optional (ra_deg, dec_deg, radius_deg) forwarded to astrometry.net as a search-position hint. Lets star-starved / narrow-FOV cameras solve with fewer stars. None = blind all-sky search (default). - verbose: [bool] Print progress information Returns: platepar: [Platepar] Fitted platepar object, or None if fitting failed @@ -555,6 +556,7 @@ def autoFitPlatepar(dir_path, config, catalog_stars, platepar_template=None, photometric_sigma=photometric_sigma, fwhm_mult=fwhm_mult, wide_fov_search=True, + position_hint=position_hint, verbose=verbose ) diff --git a/Tests/TestAstrometryNet.py b/Tests/TestAstrometryNet.py new file mode 100644 index 000000000..26f284f60 --- /dev/null +++ b/Tests/TestAstrometryNet.py @@ -0,0 +1,245 @@ +import datetime +import inspect +from types import SimpleNamespace + +import pytest + +np = pytest.importorskip("numpy") + +import RMS.Astrometry.AstrometryNet as AstrometryNet +import RMS.Astrometry.AstrometryNetNova as AstrometryNetNova +import RMS.Astrometry.AutoPlatepar as AutoPlatepar + + +class _NoMatchSolution(object): + def has_match(self): + return False + + +class _NewSolver(object): + call = None + + def __init__(self, index_files): + pass + + def solve(self, stars, size_hint, position_hint, solution_parameters): + _NewSolver.call = { + 'stars': stars, + 'position_hint': position_hint, + } + return _NoMatchSolution() + + +class _LegacySolver(object): + call = None + + def __init__(self, index_files): + pass + + def solve(self, stars_xs, stars_ys, size_hint, position_hint, solution_parameters): + _LegacySolver.call = { + 'stars_xs': stars_xs, + 'stars_ys': stars_ys, + 'position_hint': position_hint, + } + return _NoMatchSolution() + + +def _fakeAstrometry(solver_class): + return SimpleNamespace( + Solver=solver_class, + PositionHint=lambda **kwargs: SimpleNamespace(**kwargs), + SolutionParameters=lambda **kwargs: SimpleNamespace(**kwargs), + Action=SimpleNamespace(STOP='stop', CONTINUE='continue'), + series_4100=SimpleNamespace(index_files=lambda **kwargs: []), + ) + + +@pytest.mark.parametrize('solver_class', [_NewSolver, _LegacySolver]) +def test_local_solver_forwards_position_hint_for_supported_apis(monkeypatch, solver_class): + """ Position hints reach both the current and legacy astrometry solver APIs. """ + + solver_class.call = None + monkeypatch.setattr(AstrometryNet, 'astrometry', _fakeAstrometry(solver_class)) + + result = AstrometryNet.astrometryNetSolveLocal( + x_data=np.array([10.0, 20.0, 30.0]), + y_data=np.array([15.0, 25.0, 35.0]), + position_hint=(123, 45, 20), + ) + + assert result is None + assert solver_class.call is not None + hint = solver_class.call['position_hint'] + assert (hint.ra_deg, hint.dec_deg, hint.radius_deg) == (123.0, 45.0, 20.0) + + +def test_local_solver_preserves_blind_solve_default(monkeypatch): + """ Omitting a position hint keeps the original blind-solve behavior. """ + + _NewSolver.call = None + monkeypatch.setattr(AstrometryNet, 'astrometry', _fakeAstrometry(_NewSolver)) + + result = AstrometryNet.astrometryNetSolveLocal( + x_data=np.array([10.0, 20.0, 30.0]), + y_data=np.array([15.0, 25.0, 35.0]), + ) + + assert result is None + assert _NewSolver.call['position_hint'] is None + + +def test_remote_coordinate_and_image_fallbacks_preserve_position_hint(monkeypatch): + """ A failed coordinate solve keeps the hint when retrying with the image. """ + + calls = [] + + def fakeNovaSolve(**kwargs): + calls.append(kwargs) + return None if len(calls) == 1 else 'remote solution' + + monkeypatch.setattr(AstrometryNet, 'ASTROMETRY_NET_AVAILABLE', False) + monkeypatch.setattr(AstrometryNet, 'novaAstrometryNetSolve', fakeNovaSolve) + + position_hint = (123.0, 45.0, 20.0) + result = AstrometryNet.astrometryNetSolve( + img=np.zeros((10, 10), dtype=np.uint8), + x_data=np.array([1.0, 2.0]), + y_data=np.array([3.0, 4.0]), + position_hint=position_hint, + ) + + assert result == 'remote solution' + assert [call['position_hint'] for call in calls] == [position_hint, position_hint] + assert calls[0]['img'] is None + assert calls[1]['img'] is not None + + +def test_local_exception_remote_fallback_preserves_position_hint(monkeypatch): + """ A local solver error does not turn the remote retry into a blind solve. """ + + calls = [] + + def failLocalSolve(**kwargs): + raise RuntimeError('local failure') + + def fakeNovaSolve(**kwargs): + calls.append(kwargs) + return 'remote solution' + + monkeypatch.setattr(AstrometryNet, 'ASTROMETRY_NET_AVAILABLE', True) + monkeypatch.setattr(AstrometryNet, 'astrometryNetSolveLocal', failLocalSolve) + monkeypatch.setattr(AstrometryNet, 'novaAstrometryNetSolve', fakeNovaSolve) + + position_hint = (123.0, 45.0, 20.0) + result = AstrometryNet.astrometryNetSolve( + x_data=np.array([1.0, 2.0]), + y_data=np.array([3.0, 4.0]), + position_hint=position_hint, + ) + + assert result == 'remote solution' + assert calls[0]['position_hint'] == position_hint + + +def test_remote_solver_translates_position_hint_to_upload_fields(monkeypatch): + """ Remote submissions use the positional fields understood by Astrometry.net. """ + + class FakeClient(object): + upload_args = None + + def __init__(self, apiurl=None): + pass + + def login(self, api_key): + pass + + def upload(self, **kwargs): + FakeClient.upload_args = kwargs + return {'status': 'failure'} + + monkeypatch.setattr(AstrometryNetNova, 'Client', FakeClient) + + result = AstrometryNetNova.novaAstrometryNetSolve( + x_data=[1.0, 2.0], + y_data=[3.0, 4.0], + api_url='https://example.invalid/api/', + position_hint=(123, 45, 20), + ) + + assert result is False + assert FakeClient.upload_args['center_ra'] == 123.0 + assert FakeClient.upload_args['center_dec'] == 45.0 + assert FakeClient.upload_args['radius'] == 20.0 + + +def test_auto_fit_preserves_api_order_and_wide_retry_hint(monkeypatch): + """ Existing positional slots stay fixed and the wide retry keeps its position hint. """ + + parameters = list(inspect.signature(AutoPlatepar.autoFitPlatepar).parameters) + assert parameters[-4:] == [ + 'wide_fov_search', 'final_catalog_stars', 'verbose', 'position_hint'] + + ff_name = 'FF_test.bin' + star_data = np.array([ + [float(i), float(i + 1), 100.0, 1.0, 2.5, 0.0, 5.0, 0.0] + for i in range(12) + ]) + + monkeypatch.setattr(AutoPlatepar.os, 'listdir', lambda path: ['CALSTARS_test.txt']) + monkeypatch.setattr(AutoPlatepar.CALSTARS, 'readCALSTARS', + lambda path, name: ([(ff_name, star_data)], None)) + monkeypatch.setattr(AutoPlatepar, 'getMaskFile', lambda path, config: None) + monkeypatch.setattr(AutoPlatepar, 'filenameToDatetime', + lambda name: datetime.datetime(2026, 1, 1)) + monkeypatch.setattr(AutoPlatepar, 'date2JD', lambda *args: 2460000.0) + monkeypatch.setattr(AutoPlatepar, 'JD2HourAngle', lambda jd: 123.0) + + class FakePlatepar(object): + def __init__(self): + self.lat = 0.0 + self.lon = 0.0 + self.elev = 0.0 + self.X_res = 0 + self.Y_res = 0 + self.station_code = '' + + def addVignettingCoeff(self, use_flat=False): + pass + + monkeypatch.setattr(AutoPlatepar, 'Platepar', FakePlatepar) + + solver_hints = [] + monkeypatch.setattr(AutoPlatepar, 'astrometryNetSolve', + lambda **kwargs: solver_hints.append(kwargs['position_hint'])) + + recursive_calls = [] + retry_result = object() + + def fakeAutoFit(*args, **kwargs): + recursive_calls.append(kwargs) + return retry_result + + original_auto_fit = AutoPlatepar.autoFitPlatepar + monkeypatch.setattr(AutoPlatepar, 'autoFitPlatepar', fakeAutoFit) + + config = SimpleNamespace( + latitude=45.0, + longitude=16.0, + elevation=100.0, + width=1920, + height=1080, + stationID='TEST', + fov_w=75.0, + ) + position_hint = (123.0, 45.0, 20.0) + + result = original_auto_fit( + '/unused', config, np.empty((0, 3)), ff_name=ff_name, + verbose=False, position_hint=position_hint) + + assert result is retry_result + assert solver_hints == [position_hint] + assert len(recursive_calls) == 1 + assert recursive_calls[0]['wide_fov_search'] is True + assert recursive_calls[0]['position_hint'] == position_hint From b83ed7f714c46b127c1b9dd1c7fded336ed7f41d Mon Sep 17 00:00:00 2001 From: GlassOnTin Date: Mon, 24 Aug 2026 21:57:51 +0100 Subject: [PATCH 3/3] astrometry: clamp position-hint radius to the field size The local astrometry.net solver restricts the reference catalog to within the hint radius of the centre. When that radius is smaller than the image's own angular field, the outer stars are excluded and the quad match fails - a regression on wide-FOV cameras (a 15 deg hint on a ~64 deg fisheye dropped every star past 15 deg from centre, so a frame that solved blind no longer solved). Clamp radius_hint up to the field circum-radius (half-diagonal, +10% margin) before building the PositionHint, only when a FOV estimate is available. Narrow- FOV cameras are unaffected (their hint radius is already >= their small field), and the blind path is unchanged. Verified on-station (RPi5, IMX296 4mm fisheye, local astrometry.net): the previously-failing 15 deg hint now solves, identical result to the blind fallback; Tests/TestAstrometryNet.py still 7/7. Co-Authored-By: Claude Opus 4.8 (1M context) --- RMS/Astrometry/AstrometryNet.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/RMS/Astrometry/AstrometryNet.py b/RMS/Astrometry/AstrometryNet.py index a9cd41ae0..c42e5b6c0 100644 --- a/RMS/Astrometry/AstrometryNet.py +++ b/RMS/Astrometry/AstrometryNet.py @@ -210,6 +210,7 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, scales = {14, 15, 16, 17, 18, 19} size_hint = None + avg_fov_w = fov_h = None # set below when a FOV estimate is available if fov_w_range is not None: @@ -334,10 +335,21 @@ def astrometryNetSolveLocal(ff_file_path=None, img=None, mask=None, x_data=None, position_hint_obj = None if position_hint is not None: ra_hint, dec_hint, radius_hint = position_hint + + # The local solver restricts the reference catalog to within radius_hint of the hint + # centre. If that radius is smaller than the image's own angular field, the outer stars + # get excluded and the quad match fails - a regression on wide-FOV cameras (e.g. a 15 deg + # hint on a ~64 deg fisheye drops everything past 15 deg from centre). Never let the hint + # be tighter than the field's circum-radius (half-diagonal, small margin). Only when a + # FOV estimate is available (it always is from autoFitPlatepar). + if avg_fov_w is not None and fov_h is not None: + field_radius = np.hypot(avg_fov_w, fov_h)/2.0*1.1 + radius_hint = max(float(radius_hint), field_radius) + position_hint_obj = astrometry.PositionHint( ra_deg=float(ra_hint), dec_deg=float(dec_hint), radius_deg=float(radius_hint)) if verbose: - print("Using position hint: RA={:.2f} Dec={:.2f} radius={:.1f} deg".format( + print("Using position hint: RA={:.2f} Dec={:.2f} radius={:.1f} deg (>= field radius)".format( ra_hint, dec_hint, radius_hint)) # If the solver.solve has the argument "stars", use a 2D array of stars instead of stars_xs and stars_ys