Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.1] - 2026-06-03

### Added

- Concavity penalty for the L2 annotation cost function. The optimizer can occasionally
converge to a physically wrong, inverted arc — a failure mode on off-apex images or with
loose parameter bounds. The penalty detects this by checking whether the predicted arc
curves in the correct direction relative to its chord, and adds a large cost when it does
not. Enabled by default; configurable via `concavity_penalty` (bool) and
`concavity_penalty_scale` (float) kwargs in `fit_arc()`. The correct curvature direction
is auto-detected from `theta_z`, so non-standard viewing geometries (e.g. camera pointing
away from nadir) are handled correctly without any user configuration.

### Changed

- Sagitta pre-fitter (`fit_sagitta`) upgraded from a 2-D to a 3-D optimisation: the arc
apex x-position is now a free parameter alongside curvature and camera angle. This removes
a systematic radius bias on asymmetric arcs whose true apex falls outside the annotated
region.
- Sagitta pre-fitter now seeds the differential-evolution warm-start with a geometrically
computed camera angle (`arccos(r / (r + h))`) rather than the raw optimiser estimate,
avoiding a near-degeneracy that could misguide the final fit.

## [2.0.0] - 2026-05-07

### Added
Expand Down
4 changes: 4 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,10 @@ Complete workflow class for limb-based planetary radius determination. Default d
* Multiple uncertainty estimation methods (population spread, Hessian, profile likelihood)
* Flexible cost functions (gradient-field flux, L1, L2, log-L1)
* Support for multiple minimizers (differential-evolution, dual-annealing, basinhopping)
* Concavity penalty on the L2 cost that prevents convergence to wrong-curvature (inverted) arc solutions;
configurable via ``concavity_penalty`` and ``concavity_penalty_scale`` in :meth:`fit_arc`
* Sagitta pre-stage warm-start with 3-D optimizer (``fit_sagitta``) that seeds the differential-evolution
search with a geometrically correct ``theta_x`` estimate via :func:`~planet_ruler.geometry.limb_camera_angle`

MaskSegmenter
~~~~~~~~~~~~~
Expand Down
37 changes: 31 additions & 6 deletions docs/science/methodology.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,41 @@ then search parameter space to minimize this cost.
Parameter Bounds and Optimization
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Physical constraints provide bounds on free parameters. Earth's radius is approximately 6371 km
(±50 km for various reference models). Altitude for aircraft photography ranges from 5-20 km.
Camera pitch typically ranges from -90° to +90°, roll from -180° to +180°. Focal length can
Physical constraints provide bounds on free parameters. Earth's radius is approximately 6371 km
(±50 km for various reference models). Altitude for aircraft photography ranges from 5-20 km.
Camera pitch typically ranges from -90° to +90°, roll from -180° to +180°. Focal length can
often be constrained from EXIF metadata to within ±10%.

Global optimization algorithms (differential evolution, basin-hopping, dual annealing) search this
bounded parameter space to find the best-fit solution. Multi-resolution strategies for gradient-field
optimization start with coarse image resolution (fast evaluation) and progressively refine to full
Global optimization algorithms (differential evolution, basin-hopping, dual annealing) search this
bounded parameter space to find the best-fit solution. Multi-resolution strategies for gradient-field
optimization start with coarse image resolution (fast evaluation) and progressively refine to full
resolution, helping avoid local minima in the cost landscape.

Cost Function Structure and Curvature Disambiguation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The L2 annotation cost function measures the mean squared pixel distance between predicted
and observed horizon points. However, because a planetary limb arc and its mirror image
(with the curvature flipped) can produce nearly identical pixel residuals, the optimizer
can sometimes converge to a physically wrong solution: a predicted arc that curves in the
opposite direction from the true limb.

Planet Ruler resolves this ambiguity with an additive *concavity penalty* that fires when
the predicted arc curves the wrong way. The penalty evaluates the predicted arc at the
annotated x-coordinates, draws a chord between the leftmost and rightmost predictions,
and checks whether the interior of the arc lies above or below that chord:

* **∩-shaped arc** (interior above the chord): the limb peaks toward the top of the image —
the correct shape for standard high-altitude photography (e.g. an airplane window).
Penalty = 0.
* **∪-shaped arc** (interior below the chord): the limb sags toward the bottom of the image
— the wrong-curvature mirror solution. Penalty fires, adding a large cost that steers
differential evolution out of that basin.

The penalty is enabled by default. It can be disabled with ``concavity_penalty=False`` in
:meth:`~planet_ruler.observation.LimbObservation.fit_arc` if needed (for example, when
the viewing geometry is inverted and the ∪ shape is genuinely correct).

Uncertainty Quantification
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
25 changes: 25 additions & 0 deletions docs/tutorial_airplane.rst
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,31 @@ Use the crop tool to remove obstructions:
# Continue with normal workflow
obs = pr.LimbObservation("airplane_photo_cropped.jpg", config)

**"The fitted arc curves the wrong way (arc sags downward instead of peaking upward)"**

The optimizer occasionally locks onto a mirror-image solution where the predicted horizon
curves in the opposite direction from the true limb. Planet Ruler includes a *concavity penalty*
that prevents this by default, but if you see a clearly wrong fit you can try:

.. code-block:: python

# Increase the penalty weight (default is 100) to push harder out of the wrong basin
obs.fit_arc(
minimizer='differential-evolution',
max_iter=1000,
concavity_penalty_scale=500,
)

# Or re-run with a different random seed
obs.fit_arc(minimizer='differential-evolution', max_iter=1000, seed=99)

If you are photographing from an unusual angle where the horizon genuinely curves downward
(rare outside of ISS or near-vertical imagery), disable the penalty:

.. code-block:: python

obs.fit_arc(concavity_penalty=False)

**"Result varies between photos"**

Normal! Try:
Expand Down
10 changes: 6 additions & 4 deletions notebooks/tutorials/measure_your_planet.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,14 @@
"gps_altitude = get_gps_altitude(image_path)\n",
"if gps_altitude:\n",
" print(f\"\\n✓ GPS Altitude Found: {gps_altitude:.1f} meters\")\n",
" print(f\" Using this instead of your specified altitude: {altitude_m} meters\")\n",
" altitude_m = gps_altitude\n",
"else:\n",
" print(\n",
" f\"\\nGPS Altitude: Not found - using your specified altitude of {altitude_m} meters\"\n",
" )\n",
" try:\n",
" print(\n",
" f\"\\nGPS Altitude: Not found - using your specified altitude of {altitude_m} meters\"\n",
" )\n",
" except:\n",
" print(\"No GPS Altitude found - specify altitude_m or try a different image\")\n",
"\n",
"print(\"\\n\" + \"=\" * 60)"
]
Expand Down
Loading
Loading