This tutorial demonstrates the gradient-field detection method, which provides automated horizon detection without requiring ML dependencies or manual annotation.

When to Use Gradient-Field

Best for:

  • Clear, well-defined horizons with strong gradients

  • Atmospheric limbs without complex cloud structure

  • Batch processing multiple images

  • When PyTorch/ML dependencies are unavailable

  • Situations where reproducibility is critical

Not ideal for:

  • Horizons with multiple strong edges (use manual)

  • Very noisy or low-contrast images (use manual)

  • Complex cloud structures (use segmentation or manual)

Prerequisites

  • Python 3.8+ with Planet Ruler installed

  • Clear horizon photograph

  • Camera configuration file or auto-config

Step 1: Basic Gradient-Field Measurement

import planet_ruler.observation as obs
from planet_ruler.uncertainty import calculate_parameter_uncertainty

# Load observation
observation = obs.LimbObservation(
    image_filepath="demo/images/ISS_Earth_horizon.jpg",
    fit_config="config/earth_iss_1.yaml"
)

# Gradient-field detection will do nothing (it isn't needed!)
observation.detect_limb(detection_method="gradient-field")

# Fitting is where the magic happens instead
observation.fit_limb(
    loss_function="gradient_field", # Need to specify this!
    minimizer='dual-annealing',
    resolution_stages='auto',  # Multi-resolution optimization
    maxiter=3000
)

# Calculate uncertainty
result = calculate_parameter_uncertainty(
    observation, "r",
    scale_factor=1000,
    method='auto'
)

print(f"Radius: {result['uncertainty']:.1f} km")
observation.plot()

Step 2: Understanding Gradient-Field Parameters

The gradient-field method has several configurable parameters:

# Configure gradient-field detection
 observation.fit_limb(
     minimizer = "dual-annealing",
     loss_function="gradient_field",
     image_smoothing=2.0,
     kernel_smoothing=8.0,
     directional_smoothing=50,
     directional_decay_rate=0.10, # Directional smoothing fall-off.
     prefer_direction="up" # A trick that can help if you know that the horizon is darker along the top (up) or the bottom (down)
 )

Parameter Effects:

  • image_smoothing: Controls image smoothing _before_ gradient calculation. Helps with artifacts and other local minima.

    • Lower (0.5): Preserves fine details, more sensitive to noise

    • Higher (2.0): Smoother gradients, less noise sensitivity

  • kernel_smoothing: Controls image smoothing _after_ gradient calculation. Helps to homogenize noisy gradient directions.

    • Lower (1.0): Preserves fine details, more sensitive to noise

    • Higher (8.0): Smoother gradients, less noise sensitivity

  • directional_smoothing: Directional smoothing distance (along gradients). Helps avoid vanishing gradient in loss function.

    • Larger values (50): Casts a wide net to keep optimization from getting stuck. Can distort results depending on strength and how far the limb is from the edge of frame.

    • Smaller values (5): Slight speedup to optimization if already converging, but too low and fit may not converge to the true minimum.

  • directional_decay_rate: Rate of exponential decay in directional sampling

    • Larger values: Faster decay, emphasizes nearby pixels

    • Smaller values: Slower decay, includes more distant pixels

Step 3: Multi-Resolution Optimization

Gradient-field detection benefits greatly from multi-resolution optimization:

# Automatic multi-resolution (recommended)
observation.fit_limb(
    minimizer="dual-annealing",
    loss_function="gradient_field",
    resolution_stages='auto',  # Automatically generates stages
    maxiter=1000
)

# Manual multi-resolution configuration
observation.fit_limb(
    minimizer="dual-annealing"
    loss_function="gradient_field",
    resolution_stages=[4, 2, 1],  # Downsample factors: 4x → 2x → 1x
    maxiter=1000
)

# Single resolution (faster but may miss global optimum)
observation.fit_limb(
    minimizer="dual-annealing",
    loss_function="gradient_field",
    resolution_stages=None,  # No multi-resolution
    maxiter=1000
)

Multi-Resolution Benefits:

  • Avoids local minima by starting coarse

  • Progressively refines solution

  • More robust convergence

  • Slightly slower but much more reliable

Step 4: Choosing Minimizers for Gradient-Field

Different minimizers have different characteristics:

# Differential evolution: Best for complex problems
observation.fit_limb(
    minimizer='differential-evolution',
    resolution_stages='auto',
    popsize=15,
    maxiter=1000,
    seed=42
)
# Pros: Most robust, provides population for uncertainty
# Cons: Slowest

# Dual annealing: Good balance
observation.fit_limb(
    minimizer='dual-annealing',
    resolution_stages='auto',
    maxiter=1000,
    seed=42
)
# Pros: Fast, good global optimization
# Cons: No population (use Hessian for uncertainty)

# Basinhopping: Fastest
observation.fit_limb(
    minimizer='basinhopping',
    resolution_stages='auto',
    niter=100,
    seed=42
)
# Pros: Fastest optimization
# Cons: May miss global optimum, use with multi-resolution

Step 5: Visualizing Gradient-Field Results

import matplotlib.pyplot as plt
import numpy as np

# Create comprehensive visualization
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Original image
axes[0, 0].imshow(observation.image_data)
axes[0, 0].set_title("Original Image")
axes[0, 0].axis('off')

# Gradient field
observation.plot(gradient=True, ax=axes[0, 1], show=False)
axes[0, 1].set_title("Gradient Field")

# Detected limb overlay
axes[1, 0].imshow(observation.image_data)
x = np.arange(len(observation.features["limb"]))
axes[1, 0].plot(x, observation.features["limb"], 'r-', linewidth=2)
axes[1, 0].set_title("Detected Limb")
axes[1, 0].axis('off')

# Fit quality
x = np.arange(len(observation.features["limb"]))
axes[1, 1].plot(x, observation.features["limb"], 'b-',
                linewidth=2, label="Detected")

# Theoretical limb
final_params = observation.init_parameter_values.copy()
final_params.update(observation.best_parameters)
theoretical = planet_ruler.geometry.limb_arc(
    n_pix_x=len(observation.features["limb"]),
    n_pix_y=observation.image_data.shape[0],
    **final_params
)
axes[1, 1].plot(x, theoretical, 'r--',
                linewidth=2, label="Fitted model")
axes[1, 1].set_title("Fit Quality")
axes[1, 1].set_xlabel("Pixel X")
axes[1, 1].set_ylabel("Pixel Y")
axes[1, 1].legend()
axes[1, 1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Step 6: Batch Processing with Gradient-Field

The gradient-field method is ideal for batch processing:

from pathlib import Path
import pandas as pd

# Process multiple images
image_dir = Path("demo/images/")
image_files = list(image_dir.glob("*_horizon_*.jpg"))

results = []

for image_file in image_files:
    print(f"Processing {image_file.name}...")

    try:
        # Load and process
        obs = obs.LimbObservation(
            str(image_file),
            "config/earth_iss_1.yaml"
        )

        # Automated detection
        obs.fit_limb(
            minimizer='dual-annealing',
            loss_function="gradient_field",
            resolution_stages='auto',
            maxiter=500  # Reduce for speed
        )

        # Calculate uncertainty
        result = calculate_parameter_uncertainty(
            obs, "r", scale_factor=1000, method='auto'
        )

        results.append({
            'file': image_file.name,
            'radius_km': result['uncertainty'],
            'status': 'success'
        })

    except Exception as e:
        results.append({
            'file': image_file.name,
            'radius_km': None,
            'status': f'failed: {str(e)}'
        })

# Summary
df = pd.DataFrame(results)
print(df)

# Save results
df.to_csv("batch_results.csv", index=False)